aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:26:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:30:13 +0300
commit5cfe5e5e730246948b0c9428ad876b6938590aad (patch)
tree53d4bbe1aff6887f63f279eed1b5e4e11e7331b7
parent7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff)
feat: add configurable sidebar metadata tokens
-rw-r--r--docs/next/api/herdr-api.schema.json308
-rw-r--r--docs/next/website/src/content/docs/agents.mdx13
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx13
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx23
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx13
-rw-r--r--docs/next/website/src/content/docs/ja/agents.mdx13
-rw-r--r--docs/next/website/src/content/docs/ja/cli-reference.mdx13
-rw-r--r--docs/next/website/src/content/docs/ja/configuration.mdx23
-rw-r--r--docs/next/website/src/content/docs/ja/integrations.mdx13
-rw-r--r--docs/next/website/src/content/docs/ja/socket-api.mdx38
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx39
-rw-r--r--docs/next/website/src/content/docs/zh-cn/agents.mdx13
-rw-r--r--docs/next/website/src/content/docs/zh-cn/cli-reference.mdx13
-rw-r--r--docs/next/website/src/content/docs/zh-cn/configuration.mdx23
-rw-r--r--docs/next/website/src/content/docs/zh-cn/integrations.mdx13
-rw-r--r--docs/next/website/src/content/docs/zh-cn/socket-api.mdx38
-rw-r--r--docs/next/website/src/data/config-reference.json18
-rwxr-xr-xscripts/seed_navigator_demo.sh5
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/api/schema/agents.rs9
-rw-r--r--src/api/schema/common.rs22
-rw-r--r--src/api/schema/events.rs22
-rw-r--r--src/api/schema/panes.rs19
-rw-r--r--src/api/schema/tests.rs10
-rw-r--r--src/api/schema/workspaces.rs16
-rw-r--r--src/api/server.rs5
-rw-r--r--src/api/subscriptions.rs84
-rw-r--r--src/api/wait.rs1
-rw-r--r--src/app/actions.rs89
-rw-r--r--src/app/agents.rs4
-rw-r--r--src/app/api.rs25
-rw-r--r--src/app/api/panes.rs271
-rw-r--r--src/app/api/plugins/context.rs3
-rw-r--r--src/app/api/plugins/mod.rs1
-rw-r--r--src/app/api/workspaces.rs157
-rw-r--r--src/app/api_helpers.rs119
-rw-r--r--src/app/creation.rs5
-rw-r--r--src/app/input/sidebar.rs66
-rw-r--r--src/app/mod.rs65
-rw-r--r--src/app/runtime.rs43
-rw-r--r--src/app/state.rs4
-rw-r--r--src/app/terminal_titles.rs148
-rw-r--r--src/cli.rs12
-rw-r--r--src/cli/pane.rs63
-rw-r--r--src/cli/spec.rs19
-rw-r--r--src/cli/workspace.rs91
-rw-r--r--src/config.rs5
-rw-r--r--src/config/model.rs7
-rw-r--r--src/config/sidebar.rs400
-rw-r--r--src/events.rs3
-rw-r--r--src/handoff_runtime.rs2
-rw-r--r--src/main.rs15
-rw-r--r--src/metadata_tokens.rs259
-rw-r--r--src/pane.rs13
-rw-r--r--src/pane/osc.rs46
-rw-r--r--src/pane/terminal.rs18
-rw-r--r--src/persist/restore.rs2
-rw-r--r--src/persist/snapshot.rs1
-rw-r--r--src/server/headless.rs122
-rw-r--r--src/terminal/metadata.rs439
-rw-r--r--src/terminal/mod.rs2
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/terminal/state.rs229
-rw-r--r--src/terminal/title.rs64
-rw-r--r--src/ui.rs15
-rw-r--r--src/ui/mobile.rs10
-rw-r--r--src/ui/navigator.rs3
-rw-r--r--src/ui/sidebar.rs888
-rw-r--r--src/ui/sidebar/tokens.rs285
-rw-r--r--src/workspace.rs8
-rw-r--r--src/workspace/aggregate.rs13
-rw-r--r--tests/api_ping.rs54
-rw-r--r--tests/cli_wrapper.rs75
-rw-r--r--tests/cross_area.rs22
75 files changed, 3835 insertions, 1177 deletions
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
index ef6a6fdb..a769d050 100644
--- a/docs/next/api/herdr-api.schema.json
+++ b/docs/next/api/herdr-api.schema.json
@@ -116,6 +116,22 @@
{
"properties": {
"type": {
+ "const": "workspace_metadata_updated",
+ "type": "string"
+ },
+ "workspace": {
+ "$ref": "#/schemas/event/$defs/WorkspaceInfo"
+ }
+ },
+ "required": [
+ "type",
+ "workspace"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "type": {
"const": "workspace_closed",
"type": "string"
},
@@ -433,6 +449,22 @@
},
{
"properties": {
+ "pane": {
+ "$ref": "#/schemas/event/$defs/PaneInfo"
+ },
+ "type": {
+ "const": "pane_updated",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "pane"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
"pane_id": {
"type": "string"
},
@@ -594,12 +626,6 @@
"agent_status": {
"$ref": "#/schemas/event/$defs/AgentStatus"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"display_agent": {
"type": [
"string",
@@ -659,6 +685,7 @@
"enum": [
"workspace_created",
"workspace_updated",
+ "workspace_metadata_updated",
"workspace_closed",
"workspace_renamed",
"workspace_moved",
@@ -673,6 +700,7 @@
"tab_focused",
"pane_created",
"pane_closed",
+ "pane_updated",
"pane_focused",
"pane_moved",
"pane_output_changed",
@@ -704,12 +732,6 @@
"agent_status": {
"$ref": "#/schemas/event/$defs/AgentStatus"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"cwd": {
"type": [
"string",
@@ -767,12 +789,34 @@
"terminal_id": {
"type": "string"
},
+ "terminal_title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "terminal_title_stripped": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"title": {
"type": [
"string",
"null"
]
},
+ "tokens": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "maxProperties": 32,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
"workspace_id": {
"type": "string"
}
@@ -1007,6 +1051,16 @@
"minimum": 0,
"type": "integer"
},
+ "tokens": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "maxProperties": 32,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
"workspace_id": {
"type": "string"
},
@@ -2267,12 +2321,6 @@
"null"
]
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"message": {
"type": [
"string",
@@ -2364,10 +2412,6 @@
"null"
]
},
- "clear_custom_status": {
- "default": false,
- "type": "boolean"
- },
"clear_display_agent": {
"default": false,
"type": "boolean"
@@ -2380,12 +2424,6 @@
"default": false,
"type": "boolean"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"display_agent": {
"type": [
"string",
@@ -2418,9 +2456,23 @@
"null"
]
},
+ "tokens": {
+ "additionalProperties": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "maxProperties": 16,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
"ttl_ms": {
"format": "uint64",
- "minimum": 0,
+ "maximum": 86400000,
+ "minimum": 1,
"type": [
"integer",
"null"
@@ -3114,6 +3166,18 @@
{
"properties": {
"type": {
+ "const": "workspace.metadata_updated",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "type": {
"const": "workspace.renamed",
"type": "string"
}
@@ -3282,6 +3346,18 @@
{
"properties": {
"type": {
+ "const": "pane.updated",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "type": {
"const": "pane.focused",
"type": "string"
}
@@ -3574,6 +3650,52 @@
],
"type": "object"
},
+ "WorkspaceReportMetadataParams": {
+ "properties": {
+ "seq": {
+ "format": "uint64",
+ "minimum": 0,
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "source": {
+ "type": "string"
+ },
+ "tokens": {
+ "additionalProperties": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "maxProperties": 16,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
+ "ttl_ms": {
+ "format": "uint64",
+ "maximum": 86400000,
+ "minimum": 1,
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "workspace_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "workspace_id",
+ "source",
+ "tokens"
+ ],
+ "type": "object"
+ },
"WorkspaceTarget": {
"properties": {
"workspace_id": {
@@ -3990,6 +4112,22 @@
{
"properties": {
"method": {
+ "const": "workspace.report_metadata",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/schemas/request/$defs/WorkspaceReportMetadataParams"
+ }
+ },
+ "required": [
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "method": {
"const": "workspace.close",
"type": "string"
},
@@ -5046,12 +5184,6 @@
"agent_status": {
"$ref": "#/schemas/subscription_event/$defs/AgentStatus"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"display_agent": {
"type": [
"string",
@@ -5265,12 +5397,6 @@
"agent_status": {
"$ref": "#/schemas/success_response/$defs/AgentStatus"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"cwd": {
"type": [
"string",
@@ -5321,12 +5447,34 @@
"terminal_id": {
"type": "string"
},
+ "terminal_title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "terminal_title_stripped": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"title": {
"type": [
"string",
"null"
]
},
+ "tokens": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "maxProperties": 32,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
"workspace_id": {
"type": "string"
}
@@ -5496,6 +5644,22 @@
{
"properties": {
"type": {
+ "const": "workspace_metadata_updated",
+ "type": "string"
+ },
+ "workspace": {
+ "$ref": "#/schemas/success_response/$defs/WorkspaceInfo"
+ }
+ },
+ "required": [
+ "type",
+ "workspace"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "type": {
"const": "workspace_closed",
"type": "string"
},
@@ -5813,6 +5977,22 @@
},
{
"properties": {
+ "pane": {
+ "$ref": "#/schemas/success_response/$defs/PaneInfo"
+ },
+ "type": {
+ "const": "pane_updated",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "pane"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
"pane_id": {
"type": "string"
},
@@ -5974,12 +6154,6 @@
"agent_status": {
"$ref": "#/schemas/success_response/$defs/AgentStatus"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"display_agent": {
"type": [
"string",
@@ -6054,6 +6228,7 @@
"enum": [
"workspace_created",
"workspace_updated",
+ "workspace_metadata_updated",
"workspace_closed",
"workspace_renamed",
"workspace_moved",
@@ -6068,6 +6243,7 @@
"tab_focused",
"pane_created",
"pane_closed",
+ "pane_updated",
"pane_focused",
"pane_moved",
"pane_output_changed",
@@ -6435,12 +6611,6 @@
"agent_status": {
"$ref": "#/schemas/success_response/$defs/AgentStatus"
},
- "custom_status": {
- "type": [
- "string",
- "null"
- ]
- },
"cwd": {
"type": [
"string",
@@ -6498,12 +6668,34 @@
"terminal_id": {
"type": "string"
},
+ "terminal_title": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "terminal_title_stripped": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
"title": {
"type": [
"string",
"null"
]
},
+ "tokens": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "maxProperties": 32,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
"workspace_id": {
"type": "string"
}
@@ -8758,6 +8950,16 @@
"minimum": 0,
"type": "integer"
},
+ "tokens": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "maxProperties": 32,
+ "propertyNames": {
+ "pattern": "^[A-Za-z0-9_-]{1,32}$"
+ },
+ "type": "object"
+ },
"workspace_id": {
"type": "string"
},
diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index df7c4be1..b4c3146d 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -114,17 +114,22 @@ Targets accept terminal IDs, unique agent names, detected or reported agent labe
## Custom status labels
-Integrations can report a visual status label without changing semantic state.
+Integrations report lifecycle state as semantic state only. Add display customization separately with pane metadata tokens.
```bash
herdr pane report-agent w1:p1 \
--source custom:indexer \
--agent docs-bot \
- --state working \
- --custom-status indexing
+ --state working
+
+herdr pane report-metadata w1:p1 \
+ --source custom:indexer-display \
+ --token summary=indexing
```
-`state` controls waits, notifications, and rollups. `custom-status` is only display text.
+`state` controls waits, notifications, and rollups. The `summary` token is display-only and can be used as `$summary` in an Agent sidebar row.
+
+Agent sidebar rows can also opt into `terminal_title` or `terminal_title_stripped`; neither appears in the default rows. The first shows the latest safety-normalized OSC 0/2 terminal title. The second removes one recognized leading activity or spinner glyph and following whitespace. Herdr owns these values on the server; they are ephemeral across a cold restart and remain independent of metadata titles and semantic agent state. Spinner animation can therefore update the raw title without producing a pane update when the stripped text stays the same.
## Start agents from the CLI
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 81fe4dce..57db7484 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -120,6 +120,7 @@ herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [
herdr workspace get <workspace_id>
herdr workspace focus <workspace_id>
herdr workspace rename <workspace_id> <label>
+herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]
herdr workspace close <workspace_id>
```
@@ -214,7 +215,6 @@ herdr pane report-agent <pane_id> \
--agent LABEL \
--state idle|working|blocked|unknown \
[--message TEXT] \
- [--custom-status TEXT] \
[--seq N] \
[--agent-session-id ID] \
[--agent-session-path PATH]
@@ -235,18 +235,21 @@ herdr pane report-metadata <pane_id> \
[--applies-to-source ID] \
[--title TEXT|--clear-title] \
[--display-agent TEXT|--clear-display-agent] \
- [--custom-status TEXT|--clear-custom-status] \
[--state-label STATUS=TEXT] \
[--clear-state-labels] \
+ [--token NAME=VALUE] \
+ [--clear-token NAME] \
[--seq N] \
[--ttl-ms N]
```
-`STATUS` is one of `idle`, `working`, `blocked`, `done`, or `unknown`. `--agent` is a guard for the authoritative agent label. `--applies-to-source` is a guard for the active lifecycle authority source. Use `--display-agent` to change the visible name.
+`STATUS` is one of `idle`, `working`, `blocked`, `done`, or `unknown`. `--agent` and `--applies-to-source` guard only `--title`, `--display-agent`, and `--state-label`. They do not guard token patches; token reporters own clearing or TTL refresh. Use `--display-agent` to change the visible name.
-Metadata text is normalized before storage. Herdr trims surrounding whitespace, removes control characters, caps `--custom-status` at 32 characters, and caps `--title`, `--display-agent`, and each `--state-label` value at 80 characters. Empty normalized values are ignored.
+Metadata text is normalized before storage. Herdr trims surrounding whitespace, removes control characters, and caps `--title`, `--display-agent`, each `--state-label`, and token values at 80 characters. Empty normalized token values clear that key.
-`--source` and `--applies-to-source` must be 80 characters or fewer and may contain only ASCII letters, digits, colon, dot, underscore, and hyphen. `--ttl-ms` makes metadata expire automatically and must be between `1` and `86400000` milliseconds. Omit it for metadata that should stay until replaced, cleared, or the pane closes. `--seq` lets Herdr ignore stale reports from the same `--source`; stale reports are accepted by the API but ignored by pane state.
+`--token` patches one named display value; `--clear-token` removes one. Unmentioned tokens remain unchanged. Pane tokens are available to Agent sidebar rows as `$name`; workspace tokens are available to Space rows. TTL applies independently to the token keys updated by that call.
+
+`--source` and `--applies-to-source` must be 80 characters or fewer and may contain only ASCII letters, digits, colon, dot, underscore, and hyphen. `--ttl-ms` makes metadata expire automatically and must be between `1` and `86400000` milliseconds. Omit it for metadata that should stay until replaced, cleared, or the pane closes. `--seq` lets Herdr ignore stale reports from the same `--source`; stale reports are accepted by the API but ignored by pane state. A pane or workspace accepts sequenced token reports from at most 32 distinct sources during its lifetime; clearing or expiry does not release those source slots.
## Agents
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index d6c1cc2e..73bc8635 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -333,6 +333,29 @@ accent = "cyan"
The agent panel shows all agents across all spaces. `agent_panel_sort` can be `spaces` or `priority`; `workspaces` is accepted as an alias for `spaces`. `spaces` is the default and keeps agents grouped by space order. `priority` sorts by attention priority: blocked, done, working, idle, then unknown. Within the same status, agents that most recently changed state appear first.
+Configure expanded sidebar rows as ordered tokens:
+
+```toml
+[ui.sidebar.agents]
+rows = [["state_icon", "workspace", "tab"], ["agent", "$summary"]]
+
+[ui.sidebar.agents.rows_by_agent]
+claude = [
+ ["state_icon", "workspace", "tab"],
+ ["terminal_title_stripped"],
+ ["agent", "$summary"],
+]
+
+[ui.sidebar.spaces]
+rows = [["state_icon", "workspace"], ["$jj_status"]]
+```
+
+`rows` is the default Agent layout. `rows_by_agent` can replace the complete layout for a matching known agent. Its keys are Herdr's case-sensitive canonical agent IDs, such as `claude`, `codex`, and `pi`; detection aliases such as `claude-code` are not accepted. Agents without an override, including custom reported agents, use `rows`.
+
+Each sidebar layout may contain at most 16 rows, with at most 16 tokens in each row.
+
+Agent built-ins are `state_icon`, `state_text`, `workspace`, `tab`, `pane`, `agent`, `terminal_title`, and `terminal_title_stripped`. The two terminal-title built-ins are optional and do not appear in the default rows. `terminal_title` is the latest OSC 0/2 terminal title after safety normalization. `terminal_title_stripped` additionally removes one recognized leading activity or spinner glyph and following whitespace. Space built-ins are `state_icon`, `state_text`, `workspace`, `branch`, and `git_status`. Names beginning with `$` read arbitrary display metadata reported by scripts or plugins. Missing values and their separators disappear, and empty rows collapse. Layout remains user-owned: metadata reporters cannot choose rows or styling. These settings affect only the expanded desktop sidebar; collapsed and mobile views remain compact.
+
`confirm_close` controls whether closing a workspace asks for confirmation. `prompt_new_tab_name` controls whether new tabs ask for a label first.
Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs. With mouse capture enabled, Ctrl-click opens pane links when your terminal sends that modified click to Herdr. This includes macOS; Cmd-click is not reported separately from plain click while Herdr captures mouse input. Use Shift-Ctrl-click on Linux or Shift-Cmd-click on macOS for the terminal-native bypass path.
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 5f37afbc..48d3e7c9 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -258,19 +258,16 @@ Herdr resumes stored MastraCode threads with `mastracode --thread <id>`.
## Custom status labels
-Integrations can report a short visual label without changing the semantic state.
-
-For example, an agent can remain semantically `working` while showing `indexing` in the UI.
+Integrations report lifecycle state as semantic state only. For example, report an agent as `working` without adding display fields to the lifecycle report.
```bash
herdr pane report-agent w1:p1 \
--source custom:docs \
--agent docs-bot \
- --state working \
- --custom-status indexing
+ --state working
```
-User hooks that run next to a Herdr-managed integration should use metadata instead of `report-agent`. Metadata changes presentation without taking over the integration's `idle`, `working`, `blocked`, or session restore authority. `--agent` guards the report so it only applies while that authoritative agent is active. `--applies-to-source` guards the report so it only applies while that lifecycle authority source is active. `--display-agent` changes the visible name.
+User hooks that run next to a Herdr-managed integration should use metadata instead of `report-agent`. Metadata changes presentation without taking over the integration's `idle`, `working`, `blocked`, or session restore authority. `--agent` and `--applies-to-source` guard only presentation fields (`--title`, `--display-agent`, and `--state-label`). Token patches always apply; their reporter owns clearing or TTL refresh. `--display-agent` changes the visible name.
```bash
herdr pane report-metadata "$HERDR_PANE_ID" \
@@ -278,12 +275,12 @@ herdr pane report-metadata "$HERDR_PANE_ID" \
--agent claude \
--title "Refactor auth middleware" \
--display-agent "Claude: auth" \
- --custom-status "refactor auth" \
+ --token summary="refactor auth" \
--state-label working="refactoring auth" \
--ttl-ms 3600000
```
-Custom status and state labels are visual-only. Waits, notifications, and workspace rollups still use the semantic state.
+Tokens and state labels are visual-only. Waits, notifications, and workspace rollups still use the semantic state.
## Debug integration state
diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx
index 5f4a4891..1208195c 100644
--- a/docs/next/website/src/content/docs/ja/agents.mdx
+++ b/docs/next/website/src/content/docs/ja/agents.mdx
@@ -114,17 +114,22 @@ herdr agent rename reviewer --clear
## カスタムステータスラベル
-インテグレーションは、意味的な状態を変えずに表示用ステータスラベルを報告できます。
+インテグレーションは、ライフサイクル状態を意味的な状態としてのみ報告します。表示のカスタマイズは、ペインメタデータのトークンとして別に追加します。
```bash
herdr pane report-agent w1:p1 \
--source custom:indexer \
--agent docs-bot \
- --state working \
- --custom-status indexing
+ --state working
+
+herdr pane report-metadata w1:p1 \
+ --source custom:indexer-display \
+ --token summary=indexing
```
-`state` は wait、通知、ロールアップを制御します。`custom-status` は表示テキストだけです。
+`state` は wait、通知、ロールアップを制御します。`summary` トークンは表示専用で、エージェントサイドバー行では `$summary` として使えます。
+
+エージェントサイドバー行では、`terminal_title` または `terminal_title_stripped` も任意で使えます。どちらもデフォルト行には含まれません。前者は安全性のために正規化された最新の OSC 0/2 ターミナルタイトルを表示し、後者は先頭にある認識済みのアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去します。これらの値は Herdr サーバーが所有し、コールドリスタートをまたいで保持されません。また、メタデータのタイトルや意味的なエージェント状態から独立しています。そのため、除去後のテキストが同じなら、スピナーのアニメーションで生のタイトルが変わってもペイン更新は発行されません。
## CLI からエージェントを起動する
diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx
index ea15d44d..51e8365a 100644
--- a/docs/next/website/src/content/docs/ja/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx
@@ -116,6 +116,7 @@ herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [
herdr workspace get <workspace_id>
herdr workspace focus <workspace_id>
herdr workspace rename <workspace_id> <label>
+herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]
herdr workspace close <workspace_id>
```
@@ -200,7 +201,6 @@ herdr pane report-agent <pane_id> \
--agent LABEL \
--state idle|working|blocked|unknown \
[--message TEXT] \
- [--custom-status TEXT] \
[--seq N] \
[--agent-session-id ID] \
[--agent-session-path PATH]
@@ -219,18 +219,21 @@ herdr pane report-metadata <pane_id> \
[--applies-to-source ID] \
[--title TEXT|--clear-title] \
[--display-agent TEXT|--clear-display-agent] \
- [--custom-status TEXT|--clear-custom-status] \
[--state-label STATUS=TEXT] \
[--clear-state-labels] \
+ [--token NAME=VALUE] \
+ [--clear-token NAME] \
[--seq N] \
[--ttl-ms N]
```
-`STATUS` は `idle`、`working`、`blocked`、`done`、`unknown` のいずれかです。`--agent` は権威あるエージェントラベルに対するガードです。`--applies-to-source` はアクティブなライフサイクル権威ソースに対するガードです。表示名を変えるには `--display-agent` を使ってください。
+`STATUS` は `idle`、`working`、`blocked`、`done`、`unknown` のいずれかです。`--agent` と `--applies-to-source` は `--title`、`--display-agent`、`--state-label` だけを保護し、トークンパッチは保護しません。トークンのクリアまたは TTL の更新は報告側が管理します。表示名を変えるには `--display-agent` を使ってください。
-メタデータのテキストは保存前に正規化されます。Herdr は前後の空白を取り除き、制御文字を除去し、`--custom-status` を 32 文字に、`--title`、`--display-agent`、各 `--state-label` の値を 80 文字に制限します。正規化後に空になった値は無視されます。
+メタデータのテキストは保存前に正規化されます。Herdr は前後の空白を取り除き、制御文字を除去し、`--title`、`--display-agent`、各 `--state-label`、トークンの値を 80 文字に制限します。正規化後に空になったトークン値は、そのキーをクリアします。
-`--source` と `--applies-to-source` は 80 文字以下で、ASCII の英字、数字、コロン、ドット、アンダースコア、ハイフンのみを含められます。`--ttl-ms` はメタデータを自動失効させ、`1` から `86400000` ミリ秒の間でなければなりません。置き換え・クリア・ペインのクローズまで残るべきメタデータでは省略してください。`--seq` により、Herdr は同じ `--source` からの古い報告を無視できます。古い報告は API には受理されますが、ペイン状態には無視されます。
+`--token` は名前付き表示値を設定し、`--clear-token` は削除します。指定しなかったトークンは変わりません。ペイントークンはエージェントサイドバー行で `$name` として、ワークスペーストークンはスペース行で使えます。TTL は、その呼び出しで更新した各トークンキーに個別に適用されます。
+
+`--source` と `--applies-to-source` は 80 文字以下で、ASCII の英字、数字、コロン、ドット、アンダースコア、ハイフンのみを含められます。`--ttl-ms` はメタデータを自動失効させ、`1` から `86400000` ミリ秒の間でなければなりません。置き換え・クリア・ペインまたはワークスペースのクローズまで残るべきメタデータでは省略してください。`--seq` により、Herdr は同じ `--source` からの古い報告を無視できます。古い報告は API には受理されますが、ペイン状態には無視されます。ペインまたはワークスペースが存続する間、シーケンス付きトークン報告を送れる異なるソースは最大 32 個で、クリアや失効でもその枠は解放されません。
## エージェント
diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx
index b91d5bd6..72691195 100644
--- a/docs/next/website/src/content/docs/ja/configuration.mdx
+++ b/docs/next/website/src/content/docs/ja/configuration.mdx
@@ -325,6 +325,29 @@ accent = "cyan"
エージェントパネルはすべてのスペースをまたいですべてのエージェントを表示します。`agent_panel_sort` には `spaces` または `priority` を指定できます。`workspaces` は `spaces` のエイリアスとして受け付けられます。デフォルトは `spaces` で、エージェントをスペース順にグループ化したまま保ちます。`priority` は注意の優先度順に並べます: blocked、done、working、idle、そして unknown です。同じステータス内では、最近状態が変わったエージェントが先に表示されます。
+展開されたサイドバーの行は、順序付きトークンとして設定できます:
+
+```toml
+[ui.sidebar.agents]
+rows = [["state_icon", "workspace", "tab"], ["agent"]]
+
+[ui.sidebar.agents.rows_by_agent]
+claude = [
+ ["state_icon", "workspace", "tab"],
+ ["terminal_title_stripped"],
+ ["agent"],
+]
+
+[ui.sidebar.spaces]
+rows = [["state_icon", "workspace"], ["branch", "git_status", "$jj_status"]]
+```
+
+`rows` はエージェントのデフォルトレイアウトです。`rows_by_agent` は、一致する既知のエージェントについてレイアウト全体を置き換えます。キーには `claude`、`codex`、`pi` など、大文字と小文字を区別する Herdr の正規エージェント ID を使います。`claude-code` などの検出用エイリアスは使用できません。オーバーライドがないエージェントと、独自に報告されたエージェントには `rows` が使われます。
+
+各サイドバーレイアウトは最大 16 行、各行は最大 16 トークンです。
+
+エージェントの組み込みトークンは `state_icon`、`state_text`、`workspace`、`tab`、`pane`、`agent`、`terminal_title`、`terminal_title_stripped` です。2 つのターミナルタイトル用トークンは任意で、デフォルト行には含まれません。`terminal_title` は、安全性のための正規化を行った最新の OSC 0/2 ターミナルタイトルです。`terminal_title_stripped` はさらに、先頭にある認識済みのアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去します。スペースの組み込みトークンは `state_icon`、`state_text`、`workspace`、`branch`、`git_status` です。`$` で始まる名前は、スクリプトやプラグインが報告した任意の表示メタデータを参照します。値がないトークンと区切りは表示されず、空の行は折りたたまれます。これらの設定は展開されたデスクトップサイドバーだけに適用され、折りたたみ表示とモバイル表示はコンパクトなままです。
+
`confirm_close` はワークスペースを閉じるときに確認を求めるかどうかを制御します。`prompt_new_tab_name` は新しいタブで先にラベルを尋ねるかどうかを制御します。
URL の command+クリックなど、通常のクリックをターミナルに処理させたい場合は `mouse_capture = false` を設定してください。マウスキャプチャが有効な場合、ターミナルが修飾キー付きクリックを Herdr に送れば、Ctrl+クリックでペインのリンクを開けます。これは macOS も含みます。Herdr がマウス入力をキャプチャしている間、Cmd+クリックは通常のクリックと区別して報告されません。ターミナルネイティブのバイパス経路には、Linux では Shift+Ctrl+クリック、macOS では Shift+Cmd+クリックを使ってください。
diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx
index dd1b4449..de80ab39 100644
--- a/docs/next/website/src/content/docs/ja/integrations.mdx
+++ b/docs/next/website/src/content/docs/ja/integrations.mdx
@@ -258,19 +258,16 @@ Herdr は保存された MastraCode スレッドを `mastracode --thread <id>` ã
## カスタムステータスラベル
-インテグレーションは、意味的な状態を変えずに短い表示用ラベルを報告できます。
-
-たとえば、エージェントは意味的には `working` のまま、UI には `indexing` を表示できます。
+インテグレーションは、ライフサイクル状態を意味的な状態としてのみ報告します。たとえば、ライフサイクル報告に表示フィールドを加えず、エージェントを `working` として報告します。
```bash
herdr pane report-agent w1:p1 \
--source custom:docs \
--agent docs-bot \
- --state working \
- --custom-status indexing
+ --state working
```
-Herdr 管理のインテグレーションと並走するユーザーフックは、`report-agent` ではなくメタデータを使うべきです。メタデータは、インテグレーションの `idle`、`working`、`blocked` やセッション復元の権威を奪わずに表示を変えます。`--agent` は、その権威あるエージェントがアクティブな間だけ報告が適用されるように保護します。`--applies-to-source` は、そのライフサイクル権威ソースがアクティブな間だけ報告が適用されるように保護します。`--display-agent` は表示名を変更します。
+Herdr 管理のインテグレーションと並走するユーザーフックは、`report-agent` ではなくメタデータを使うべきです。メタデータは、インテグレーションの `idle`、`working`、`blocked` やセッション復元の権威を奪わずに表示を変えます。`--agent` と `--applies-to-source` は表示フィールド (`--title`、`--display-agent`、`--state-label`) だけを保護します。トークンパッチは常に適用され、クリアまたは TTL の更新は報告側が管理します。`--display-agent` は表示名を変更します。
```bash
herdr pane report-metadata "$HERDR_PANE_ID" \
@@ -278,12 +275,12 @@ herdr pane report-metadata "$HERDR_PANE_ID" \
--agent claude \
--title "Refactor auth middleware" \
--display-agent "Claude: auth" \
- --custom-status "refactor auth" \
+ --token summary="refactor auth" \
--state-label working="refactoring auth" \
--ttl-ms 3600000
```
-カスタムステータスと状態ラベルは表示専用です。wait、通知、ワークスペースのロールアップは引き続き意味的な状態を使います。
+トークンと状態ラベルは表示専用です。wait、通知、ワークスペースのロールアップは引き続き意味的な状態を使います。
## インテグレーション状態のデバッグ
diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx
index a5ebc5f5..6e866105 100644
--- a/docs/next/website/src/content/docs/ja/socket-api.mdx
+++ b/docs/next/website/src/content/docs/ja/socket-api.mdx
@@ -95,7 +95,7 @@ herdr pane read w1:p2 --source recent --lines 50
| サーバー | `ping`、`server.stop`、`server.reload_config`、`server.agent_manifests`、`server.reload_agent_manifests` |
| 通知 | `notification.show` |
| クライアント | `client.window_title.set`、`client.window_title.clear` |
-| ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.close` |
+| ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.report_metadata`、`workspace.close` |
| Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` |
| タブ | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.close` |
| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` |
@@ -415,15 +415,12 @@ Herdr はローカルソケット上の改行区切り JSON を使います。Un
"source": "custom:docs",
"agent": "docs-bot",
"state": "working",
- "message": "building docs",
- "custom_status": "indexing"
+ "message": "building docs"
}
}
```
-`state` は意味的です。wait、通知、ロールアップに影響します。
-
-`custom_status` は表示用です。意味的な挙動を変えずに `indexing` のような短いアクティビティラベルを表示できます。
+`state` は意味的です。wait、通知、ロールアップに影響します。表示専用の値はメタデータで別に報告します。
セッションのみの公式インテグレーションは、`pane.report_agent_session` でネイティブセッション参照を報告します。状態を報告するインテグレーションは、`pane.report_agent` にネイティブセッション参照を含めることもできます。状態に依存しないセッション報告は、wait、通知、ロールアップに影響しません。
@@ -457,6 +454,8 @@ Herdr に保存されたネイティブセッション参照があるとき、`p
`pane.get`、`pane.list`、`agent.get`、`agent.list` は、ペインの PTY を現在制御しているプロセスの cwd を解決できるときに `foreground_cwd` も公開します。既存の `cwd` フィールドは、ラベル、follow-cwd 挙動、復元されたセッション状態に使われるペイン/ワークスペースの cwd のままです。
+`PaneInfo` と `AgentInfo` は、任意の `terminal_title` と `terminal_title_stripped` フィールドを公開します。`terminal_title` は、安全性のための正規化を行った最新の OSC 0/2 タイトルです。`terminal_title_stripped` は、先頭にある認識済みのアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去します。これらはサーバーが所有する値で、コールドリスタートをまたいで保持されず、メタデータの `title` や意味的なエージェント状態から独立しています。
+
ユーザーフックが Herdr インテグレーションからライフサイクル状態を奪わずに表示をカスタマイズしたいときは `pane.report_metadata` を使ってください。
```json
@@ -469,26 +468,39 @@ Herdr に保存されたネイティブセッション参照があるとき、`p
"agent": "claude",
"title": "Refactor auth middleware",
"display_agent": "Claude: auth",
- "custom_status": "refactor auth",
"state_labels": {
"working": "refactoring auth",
"idle": "ready",
"done": "review ready"
},
+ "tokens": {
+ "summary": "refactor auth",
+ "model": "opus"
+ },
"ttl_ms": 3600000
}
}
```
-メタデータ報告は表示専用です。有効なメタデータは、ペインのタイトル、表示されるエージェント名、コンパクトなアクティビティラベル、可視の状態ラベルを上書きできます。`working`、`blocked`、`idle`、wait、通知、ロールアップは引き続き意味的な状態から得られます。エージェントネイティブのセッション復元は、保存された公式セッション参照から得られます。`agent` は権威あるエージェントラベルに対する任意のガードで、`applies_to_source` はアクティブなライフサイクル権威ソースに対する任意のガードです。表示名を変えるには `display_agent` を使います。`state_labels` のキーは `idle`、`working`、`blocked`、`done`、`unknown` のいずれかでなければなりません。ひとつの表示上書きを取り除くには、同じ `source` で `clear_custom_status: true` のようなクリアフィールドを使ってください。
+メタデータ報告は表示専用です。有効なメタデータは、ペインのタイトル、表示されるエージェント名、可視の状態ラベル、任意の名前付きトークンを上書きできます。`working`、`blocked`、`idle`、wait、通知、ロールアップは引き続き意味的な状態から得られます。エージェントネイティブのセッション復元は、保存された公式セッション参照から得られます。`agent` は表示フィールドを権威あるエージェントラベルに対して保護する任意のガードで、`applies_to_source` も表示フィールドをアクティブなライフサイクル権威ソースに対して保護します。これらのガードはトークンパッチには適用されません。トークンのクリアと TTL の更新は報告側が管理します。表示名を変えるには `display_agent` を使います。`state_labels` のキーは `idle`、`working`、`blocked`、`done`、`unknown` のいずれかでなければなりません。
+
+トークンマップはリソースごとのパッチです。文字列はキーを設定し、JSON の `null` はクリアし、省略したキーは変わりません。最後に受理された更新が優先されます。任意の TTL は、その報告で更新した各トークンキーに個別に適用されます。ペイントークンは pane と agent の get/list 応答で公開され、エージェントサイドバー行では `$name` として描画できます。1 回の報告で指定できるトークンキーは最大 16 個、ペインまたはワークスペースが保持できるキーは最大 32 個です。トークン名は 1〜32 文字の ASCII 英数字、アンダースコア、ハイフンに限られます。
+
+ワークスペーストークンも同じ規約を使います:
+
+```json
+{"id":"req_3","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user:jj","tokens":{"jj_status":"2 changes","old":null},"ttl_ms":5000}}
+```
+
+workspace の get/list 応答は結果の `tokens` マップを公開し、スペースサイドバー行では `$jj_status` のような値を描画できます。変更と TTL の失効は、最新のワークスペーススナップショットを含む `workspace.metadata_updated` を発行します。このメタデータイベントは API 購読者に届きますが、プラグインイベントフックは実行しません。
-表示テキストは保存前に正規化されます。Herdr は前後の空白を取り除き、制御文字を除去し、`custom_status` を 32 文字に、`title`、`display_agent`、各状態ラベルを 80 文字に制限します。正規化後に空になった値は無視されます。
+表示テキストは保存前に正規化されます。Herdr は前後の空白を取り除き、制御文字を除去し、`title`、`display_agent`、各状態ラベル、トークン値を 80 文字に制限します。正規化後に空になったトークン値は、そのキーをクリアします。
`source` と `applies_to_source` はソース識別子です。80 文字以下で、ASCII の英字、数字、コロン、ドット、アンダースコア、ハイフンのみを含められます。
-短寿命のメタデータには `ttl_ms` を使ってください。`1` から `86400000` ミリ秒の間でなければなりません。置き換え・クリア・ペインのクローズまで残るべきメタデータでは省略します。TTL が切れると、Herdr はそのソースのメタデータを削除し、可視のペイン表示が変わった場合は表示変更イベントを発行します。
+短寿命のメタデータには `ttl_ms` を使ってください。`1` から `86400000` ミリ秒の間でなければなりません。置き換え・クリア・ペインまたはワークスペースのクローズまで残るべきメタデータでは省略します。表示フィールドは従来どおりソース単位で失効し、呼び出しで更新した各トークンには個別の期限が設定されます。トークンメタデータはサーバー再起動後に復元されません。
-フックが順不同で更新を送る可能性がある場合は `seq` を使ってください。同じ `source` について、最後に受理されたシーケンス以下のシーケンス番号を持つ報告は、API には受理されますがペイン状態には無視されます。
+フックが順不同で更新を送る可能性がある場合は `seq` を使ってください。同じ `source` について、最後に受理されたシーケンス以下のシーケンス番号を持つ報告は、API には受理されますがペイン状態には無視されます。ペインまたはワークスペースが存続する間、シーケンス付きトークン報告を送れる異なるソースは最大 32 個です。クリアや失効でもそのソース枠は解放されません。
## イベント購読
@@ -508,8 +520,8 @@ Herdr に保存されたネイティブセッション参照があるとき、`p
最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。
-ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.renamed`、`workspace.closed`、`workspace.focused` があります。ワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。
-ペインのイベント購読には `pane.created`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` があります。
+ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。
+ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` があります。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。
worktree のイベント購読には `worktree.created`、`worktree.opened`、`worktree.removed` があります。worktree イベントは Git チェックアウトのライフサイクルを記述します。`worktree.created` は開かれた `workspace` と作成された `worktree` を含みます。`worktree.opened` は対象の `workspace`、開かれた `worktree`、`already_open` を含みます。`worktree.removed` は `workspace_id`、削除された `worktree`、`forced` を含みます。
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 600238b9..c02a83d1 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -100,7 +100,7 @@ Raw socket method names use dot notation:
| Notification | `notification.show` |
| Client | `client.window_title.set`, `client.window_title.clear` |
| Session | `session.snapshot` |
-| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.close` |
+| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.report_metadata`, `workspace.close` |
| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` |
| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` |
| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
@@ -542,15 +542,12 @@ Integrations report agent state with `pane.report_agent`.
"source": "custom:docs",
"agent": "docs-bot",
"state": "working",
- "message": "building docs",
- "custom_status": "indexing"
+ "message": "building docs"
}
}
```
-`state` is semantic. It affects waits, notifications, and rollups.
-
-`custom_status` is visual. It can show a short activity label like `indexing` without changing semantic behavior.
+`state` is semantic. It affects waits, notifications, and rollups. Report display-only values separately through metadata.
Session-only official integrations report native session references with `pane.report_agent_session`. State-reporting integrations can still include native session references in `pane.report_agent`. State-independent session reports do not affect waits, notifications, or rollups.
@@ -584,6 +581,8 @@ If no native session reference is stored, the field is omitted.
`pane.get`, `pane.list`, `agent.get`, and `agent.list` also expose `foreground_cwd` when Herdr can resolve the cwd of the process currently controlling the pane PTY. The existing `cwd` field remains the pane/workspace cwd used for labels, follow-cwd behavior, and restored session state.
+`PaneInfo` and `AgentInfo` expose optional `terminal_title` and `terminal_title_stripped` fields. `terminal_title` is the latest OSC 0/2 title after safety normalization. `terminal_title_stripped` removes one recognized leading activity or spinner glyph and following whitespace. These server-owned values are ephemeral across a cold restart and are independent of the metadata `title` and semantic agent state.
+
Use `pane.report_metadata` when a user hook wants to customize presentation without taking over lifecycle state from a Herdr integration.
```json
@@ -596,26 +595,39 @@ Use `pane.report_metadata` when a user hook wants to customize presentation with
"agent": "claude",
"title": "Refactor auth middleware",
"display_agent": "Claude: auth",
- "custom_status": "refactor auth",
"state_labels": {
"working": "refactoring auth",
"idle": "ready",
"done": "review ready"
},
+ "tokens": {
+ "summary": "refactor auth",
+ "model": "opus"
+ },
"ttl_ms": 3600000
}
}
```
-Metadata reports are display-only. Valid metadata can override the pane title, displayed agent name, compact activity label, and visible state labels. `working`, `blocked`, `idle`, waits, notifications, and rollups still come from semantic state. Native session restore comes from stored official session references. `agent` is an optional guard for the authoritative agent label; `applies_to_source` is an optional guard for the active lifecycle authority source. Use `display_agent` to change the visible name. `state_labels` keys must be `idle`, `working`, `blocked`, `done`, or `unknown`. Use clear fields such as `clear_custom_status: true` with the same `source` to remove one presentation override.
+Metadata reports are display-only. Valid metadata can override the pane title, displayed agent name, visible state labels, and arbitrary named tokens. `working`, `blocked`, `idle`, waits, notifications, and rollups still come from semantic state. Native session restore comes from stored official session references. `agent` is an optional guard for presentation fields against the authoritative agent label; `applies_to_source` similarly guards presentation fields against the active lifecycle authority source. These guards do not apply to token patches: token reporters own clearing and TTL refresh. Use `display_agent` to change the visible name. `state_labels` keys must be `idle`, `working`, `blocked`, `done`, or `unknown`.
+
+Token maps are per-resource patches. A string sets a key, JSON `null` clears it, and omitted keys remain unchanged. The latest accepted update wins. Optional TTL applies independently to token keys updated by that report. Pane tokens are exposed by pane and agent get/list responses and can be rendered as `$name` in Agent sidebar rows. A report may mention at most 16 token keys, and a pane or workspace may retain at most 32 keys. Token names are 1–32 ASCII letters, digits, underscores, or hyphens.
+
+Workspace tokens use the same contract:
+
+```json
+{"id":"req_3","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user:jj","tokens":{"jj_status":"2 changes","old":null},"ttl_ms":5000}}
+```
+
+Workspace get/list responses expose the resulting `tokens` map, and Space sidebar rows can render values such as `$jj_status`. Changes and TTL expiry emit `workspace.metadata_updated` with the latest workspace snapshot. This metadata event is available to API subscribers but does not invoke plugin event hooks.
-Presentation text is normalized before storage. Herdr trims surrounding whitespace, removes control characters, caps `custom_status` at 32 characters, and caps `title`, `display_agent`, and each state label at 80 characters. Empty normalized values are ignored.
+Presentation text is normalized before storage. Herdr trims surrounding whitespace, removes control characters, and caps `title`, `display_agent`, each state label, and token values at 80 characters. Empty normalized token values clear that key.
`source` and `applies_to_source` are source identifiers. They must be 80 characters or fewer and may contain only ASCII letters, digits, colon, dot, underscore, and hyphen.
-Use `ttl_ms` for short-lived metadata. It must be between `1` and `86400000` milliseconds. Omit `ttl_ms` for metadata that should stay until replaced, cleared, or the pane closes. When the TTL expires, Herdr removes that source's metadata and emits a presentation change if the visible pane presentation changed.
+Use `ttl_ms` for short-lived metadata. It must be between `1` and `86400000` milliseconds. Omit `ttl_ms` for metadata that should stay until replaced, cleared, or the pane/workspace closes. Presentation fields retain their existing source-scoped expiry behavior; each token updated by the call receives its own deadline. Token metadata is not restored after a server restart.
-Use `seq` when a hook may send updates out of order. For the same `source`, reports with a sequence number less than or equal to the last accepted sequence are accepted by the API but ignored by the pane state.
+Use `seq` when a hook may send updates out of order. For the same `source`, reports with a sequence number less than or equal to the last accepted sequence are accepted by the API but ignored by the pane state. A pane or workspace accepts sequenced token reports from at most 32 distinct sources during its lifetime; clearing or expiry does not release those source slots.
## Event subscriptions
@@ -635,14 +647,15 @@ Subscribe to events when you need a long-lived stream:
The first response acknowledges the subscription. Later lines are pushed events.
-Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.moved`, `workspace.closed`, and `workspace.focused`. Workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
+Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`,
`tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`,
`workspace_id`, requested `insert_index`, and updated ordered `tabs` list for
that workspace.
-Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`,
+Pane event subscriptions include `pane.created`, `pane.updated`, `pane.closed`, `pane.focused`,
`pane.moved`, `pane.exited`, `pane.agent_detected`,
`pane.output_matched`, `pane.agent_status_changed`, and `pane.scroll_changed`.
+Terminal-title changes can emit `pane.updated`, but spinner-only raw-title changes do not emit it when `terminal_title_stripped` is unchanged.
`pane.scroll_changed` is scoped to one `pane_id` and emits `pane_id`,
`workspace_id`, and the current `scroll` metrics whenever Herdr observes a
changed scroll snapshot.
diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx
index cd351745..f169fbe1 100644
--- a/docs/next/website/src/content/docs/zh-cn/agents.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx
@@ -114,17 +114,22 @@ herdr agent rename reviewer --clear
## 自定义状态标签
-集成可以上报一个视觉状态标签,而不改变语义状态。
+集成只把生命周期状态作为语义状态上报。展示自定义应通过窗格元数据令牌单独添加。
```bash
herdr pane report-agent w1:p1 \
--source custom:indexer \
--agent docs-bot \
- --state working \
- --custom-status indexing
+ --state working
+
+herdr pane report-metadata w1:p1 \
+ --source custom:indexer-display \
+ --token summary=indexing
```
-`state` 控制等待、通知和汇总。`custom-status` 只是显示文本。
+`state` 控制等待、通知和汇总。`summary` 令牌只影响展示,可在智能体侧边栏行中写成 `$summary`。
+
+智能体侧边栏行也可以选择使用 `terminal_title` 或 `terminal_title_stripped`;两者都不在默认行中。前者显示经过安全规范化的最新 OSC 0/2 终端标题,后者会移除开头一个已识别的活动或旋转指示符字形及其后的空白。这些值由 Herdr 服务器所有,冷重启后不会保留,并且独立于元数据标题和语义智能体状态。因此,如果移除后的文本不变,旋转动画可以改变原始标题而不触发窗格更新。
## 从 CLI 启动智能体
diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx
index c32b14bc..6f1e88a3 100644
--- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx
@@ -116,6 +116,7 @@ herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [
herdr workspace get <workspace_id>
herdr workspace focus <workspace_id>
herdr workspace rename <workspace_id> <label>
+herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]
herdr workspace close <workspace_id>
```
@@ -200,7 +201,6 @@ herdr pane report-agent <pane_id> \
--agent LABEL \
--state idle|working|blocked|unknown \
[--message TEXT] \
- [--custom-status TEXT] \
[--seq N] \
[--agent-session-id ID] \
[--agent-session-path PATH]
@@ -219,18 +219,21 @@ herdr pane report-metadata <pane_id> \
[--applies-to-source ID] \
[--title TEXT|--clear-title] \
[--display-agent TEXT|--clear-display-agent] \
- [--custom-status TEXT|--clear-custom-status] \
[--state-label STATUS=TEXT] \
[--clear-state-labels] \
+ [--token NAME=VALUE] \
+ [--clear-token NAME] \
[--seq N] \
[--ttl-ms N]
```
-`STATUS` 是 `idle`、`working`、`blocked`、`done` 或 `unknown` 之一。`--agent` 是对权威智能体标签的守卫。`--applies-to-source` 是对活动生命周期权威来源的守卫。用 `--display-agent` 修改可见名称。
+`STATUS` 是 `idle`、`working`、`blocked`、`done` 或 `unknown` 之一。`--agent` 和 `--applies-to-source` 只守卫 `--title`、`--display-agent` 和 `--state-label`,不守卫令牌补丁。上报方负责清除令牌或刷新 TTL。用 `--display-agent` 修改可见名称。
-元数据文本在存储前会被规范化。Herdr 去掉首尾空白、移除控制字符、把 `--custom-status` 截断到 32 个字符,把 `--title`、`--display-agent` 和每个 `--state-label` 的值截断到 80 个字符。规范化后为空的值被忽略。
+元数据文本在存储前会被规范化。Herdr 去掉首尾空白、移除控制字符,并把 `--title`、`--display-agent`、每个 `--state-label` 和令牌值截断到 80 个字符。规范化后为空的令牌值会清除该键。
-`--source` 和 `--applies-to-source` 必须不超过 80 个字符,并且只能包含 ASCII 字母、数字、冒号、点、下划线和连字符。`--ttl-ms` 让元数据自动过期,取值必须在 `1` 到 `86400000` 毫秒之间。想让元数据一直保留到被替换、清除或窗格关闭时,省略它。`--seq` 让 Herdr 忽略来自同一 `--source` 的过期上报;过期上报会被 API 接受,但被窗格状态忽略。
+`--token` 设置一个命名展示值,`--clear-token` 删除一个值。未提及的令牌保持不变。窗格令牌可在智能体侧边栏行中写成 `$name`;工作区令牌可用于空间行。TTL 分别应用于该次调用更新的每个令牌键。
+
+`--source` 和 `--applies-to-source` 必须不超过 80 个字符,并且只能包含 ASCII 字母、数字、冒号、点、下划线和连字符。`--ttl-ms` 让元数据自动过期,取值必须在 `1` 到 `86400000` 毫秒之间。想让元数据一直保留到被替换、清除或窗格或工作区关闭时,省略它。`--seq` 让 Herdr 忽略来自同一 `--source` 的过期上报;过期上报会被 API 接受,但被窗格状态忽略。每个窗格或工作区在其生命周期内最多接受来自 32 个不同来源的带序号令牌上报,清除或过期不会释放这些来源名额。
## 智能体
diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
index 6eb429fa..6262eda7 100644
--- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
@@ -325,6 +325,29 @@ accent = "cyan"
智能体面板显示所有空间中的所有智能体。`agent_panel_sort` 可以是 `spaces` 或 `priority`;`workspaces` 作为 `spaces` 的别名被接受。默认是 `spaces`,让智能体按空间顺序分组。`priority` 按关注优先级排序: blocked、done、working、idle,然后是 unknown。同一状态内,最近改变状态的智能体排在前面。
+展开侧边栏的行可以配置为有序令牌:
+
+```toml
+[ui.sidebar.agents]
+rows = [["state_icon", "workspace", "tab"], ["agent"]]
+
+[ui.sidebar.agents.rows_by_agent]
+claude = [
+ ["state_icon", "workspace", "tab"],
+ ["terminal_title_stripped"],
+ ["agent"],
+]
+
+[ui.sidebar.spaces]
+rows = [["state_icon", "workspace"], ["branch", "git_status", "$jj_status"]]
+```
+
+`rows` 是智能体的默认布局。`rows_by_agent` 会为匹配的已知智能体完整替换该布局。键必须使用 Herdr 区分大小写的规范智能体 ID,例如 `claude`、`codex` 和 `pi`;不能使用 `claude-code` 等检测别名。没有覆盖项的智能体以及自定义上报的智能体都会使用 `rows`。
+
+每个侧边栏布局最多包含 16 行,每行最多包含 16 个令牌。
+
+智能体内置令牌有 `state_icon`、`state_text`、`workspace`、`tab`、`pane`、`agent`、`terminal_title` 和 `terminal_title_stripped`。两个终端标题令牌都是可选的,不在默认行中。`terminal_title` 是经过安全规范化的最新 OSC 0/2 终端标题。`terminal_title_stripped` 还会移除开头一个已识别的活动或旋转指示符字形及其后的空白。空间内置令牌有 `state_icon`、`state_text`、`workspace`、`branch` 和 `git_status`。以 `$` 开头的名称读取脚本或插件上报的任意展示元数据。缺失值及其分隔符不会显示,空行会折叠。这些设置只影响展开的桌面侧边栏;折叠和移动端视图仍保持紧凑。
+
`confirm_close` 控制关闭工作区时是否需要确认。`prompt_new_tab_name` 控制新标签页是否先询问名称。
如果想让终端处理普通点击,比如 command+点击 URL,设置 `mouse_capture = false`。鼠标捕获启用时,如果终端把带修饰键的点击传给 Herdr,Ctrl+点击可以打开窗格链接。macOS 也是如此;Herdr 捕获鼠标输入期间,Cmd+点击不会与普通点击区分上报。终端原生的绕过路径: Linux 上用 Shift+Ctrl+点击,macOS 上用 Shift+Cmd+点击。
diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx
index 1f7d0787..2e494e12 100644
--- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx
@@ -258,19 +258,16 @@ Herdr 用 `mastracode --thread <id>` 恢复保存的 MastraCode 线程。
## 自定义状态标签
-集成可以上报一个简短的视觉标签,而不改变语义状态。
-
-例如,一个智能体可以在语义上保持 `working`,同时在 UI 中显示 `indexing`。
+集成只把生命周期状态作为语义状态上报。例如,上报智能体为 `working` 时,不在生命周期报告中添加展示字段。
```bash
herdr pane report-agent w1:p1 \
--source custom:docs \
--agent docs-bot \
- --state working \
- --custom-status indexing
+ --state working
```
-与 Herdr 管理的集成并行运行的用户钩子,应该使用元数据而不是 `report-agent`。元数据只改变展示,不会夺走集成对 `idle`、`working`、`blocked` 或会话恢复的权威。`--agent` 是守卫,使上报只在该权威智能体处于活动状态时生效。`--applies-to-source` 是守卫,使上报只在该生命周期权威来源处于活动状态时生效。`--display-agent` 修改显示名称。
+与 Herdr 管理的集成并行运行的用户钩子,应该使用元数据而不是 `report-agent`。元数据只改变展示,不会夺走集成对 `idle`、`working`、`blocked` 或会话恢复的权威。`--agent` 和 `--applies-to-source` 只守卫展示字段 (`--title`、`--display-agent` 和 `--state-label`)。令牌补丁始终生效;上报方负责清除或刷新 TTL。`--display-agent` 修改显示名称。
```bash
herdr pane report-metadata "$HERDR_PANE_ID" \
@@ -278,12 +275,12 @@ herdr pane report-metadata "$HERDR_PANE_ID" \
--agent claude \
--title "Refactor auth middleware" \
--display-agent "Claude: auth" \
- --custom-status "refactor auth" \
+ --token summary="refactor auth" \
--state-label working="refactoring auth" \
--ttl-ms 3600000
```
-自定义状态和状态标签只影响视觉。等待、通知和工作区汇总仍使用语义状态。
+令牌和状态标签只影响视觉。等待、通知和工作区汇总仍使用语义状态。
## 调试集成状态
diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx
index 7454ecc7..553ce363 100644
--- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx
@@ -95,7 +95,7 @@ herdr pane read w1:p2 --source recent --lines 50
| 服务器 | `ping`、`server.stop`、`server.reload_config`、`server.agent_manifests`、`server.reload_agent_manifests` |
| 通知 | `notification.show` |
| 客户端 | `client.window_title.set`、`client.window_title.clear` |
-| 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.close` |
+| 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.report_metadata`、`workspace.close` |
| Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` |
| 标签页 | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.close` |
| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` |
@@ -415,15 +415,12 @@ Herdr 在本地 socket 上使用换行分隔的 JSON。在 Unix 上,那个 socke
"source": "custom:docs",
"agent": "docs-bot",
"state": "working",
- "message": "building docs",
- "custom_status": "indexing"
+ "message": "building docs"
}
}
```
-`state` 是语义性的。它影响等待、通知和汇总。
-
-`custom_status` 是视觉性的。它可以显示 `indexing` 这类简短的活动标签,而不改变语义行为。
+`state` 是语义性的。它影响等待、通知和汇总。只影响展示的值应通过元数据单独上报。
仅提供会话的官方集成用 `pane.report_agent_session` 上报原生会话引用。上报状态的集成仍然可以在 `pane.report_agent` 中包含原生会话引用。与状态无关的会话上报不影响等待、通知或汇总。
@@ -457,6 +454,8 @@ Herdr 存有原生会话引用时,`pane.get`、`pane.list`、`agent.get` 和 `ag
当 Herdr 能解析当前控制窗格 PTY 的进程的 cwd 时,`pane.get`、`pane.list`、`agent.get` 和 `agent.list` 也暴露 `foreground_cwd`。已有的 `cwd` 字段仍然是用于标签、follow-cwd 行为和恢复会话状态的窗格/工作区 cwd。
+`PaneInfo` 和 `AgentInfo` 暴露可选的 `terminal_title` 和 `terminal_title_stripped` 字段。`terminal_title` 是经过安全规范化的最新 OSC 0/2 标题。`terminal_title_stripped` 会移除开头一个已识别的活动或旋转指示符字形及其后的空白。这些值归服务器所有,冷重启后不会保留,并且独立于元数据 `title` 和语义智能体状态。
+
当用户钩子想自定义展示、又不从 Herdr 集成接管生命周期状态时,使用 `pane.report_metadata`。
```json
@@ -469,26 +468,39 @@ Herdr 存有原生会话引用时,`pane.get`、`pane.list`、`agent.get` 和 `ag
"agent": "claude",
"title": "Refactor auth middleware",
"display_agent": "Claude: auth",
- "custom_status": "refactor auth",
"state_labels": {
"working": "refactoring auth",
"idle": "ready",
"done": "review ready"
},
+ "tokens": {
+ "summary": "refactor auth",
+ "model": "opus"
+ },
"ttl_ms": 3600000
}
}
```
-元数据上报只影响展示。有效的元数据可以覆盖窗格标题、显示的智能体名称、紧凑的活动标签和可见的状态标签。`working`、`blocked`、`idle`、等待、通知和汇总仍来自语义状态。原生会话恢复来自存储的官方会话引用。`agent` 是对权威智能体标签的可选守卫;`applies_to_source` 是对活动生命周期权威来源的可选守卫。用 `display_agent` 修改可见名称。`state_labels` 的键必须是 `idle`、`working`、`blocked`、`done` 或 `unknown`。用相同 `source` 搭配 `clear_custom_status: true` 这类清除字段,可以移除某一项展示覆盖。
+元数据上报只影响展示。有效的元数据可以覆盖窗格标题、显示的智能体名称、可见的状态标签和任意命名令牌。`working`、`blocked`、`idle`、等待、通知和汇总仍来自语义状态。原生会话恢复来自存储的官方会话引用。`agent` 是展示字段针对权威智能体标签的可选守卫;`applies_to_source` 同样保护展示字段,使其只适用于活动生命周期权威来源。这些守卫不作用于令牌补丁;令牌上报方负责清除和刷新 TTL。用 `display_agent` 修改可见名称。`state_labels` 的键必须是 `idle`、`working`、`blocked`、`done` 或 `unknown`。
+
+令牌映射是针对每个资源的补丁。字符串设置键,JSON `null` 清除键,未提及的键保持不变。最后接受的更新生效。可选 TTL 分别应用于该次上报更新的每个令牌键。窗格令牌会在 pane 和 agent 的 get/list 响应中公开,并可在智能体侧边栏行中写成 `$name`。单次上报最多可包含 16 个令牌键,每个窗格或工作区最多保留 32 个键。令牌名称必须由 1–32 个 ASCII 字母、数字、下划线或连字符组成。
+
+工作区令牌使用相同约定:
+
+```json
+{"id":"req_3","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user:jj","tokens":{"jj_status":"2 changes","old":null},"ttl_ms":5000}}
+```
+
+workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏行可显示 `$jj_status` 这类值。变更和 TTL 到期会发出包含最新工作区快照的 `workspace.metadata_updated`。该元数据事件会发送给 API 订阅者,但不会调用插件事件钩子。
-展示文本在存储前被规范化。Herdr 去掉首尾空白、移除控制字符、把 `custom_status` 截断到 32 个字符,把 `title`、`display_agent` 和每个状态标签截断到 80 个字符。规范化后为空的值被忽略。
+展示文本在存储前被规范化。Herdr 去掉首尾空白、移除控制字符,并把 `title`、`display_agent`、每个状态标签和令牌值截断到 80 个字符。规范化后为空的令牌值会清除该键。
`source` 和 `applies_to_source` 是来源标识符。它们必须不超过 80 个字符,且只能包含 ASCII 字母、数字、冒号、点、下划线和连字符。
-短期元数据用 `ttl_ms`。取值必须在 `1` 到 `86400000` 毫秒之间。想让元数据保留到被替换、清除或窗格关闭时,省略 `ttl_ms`。TTL 过期时,Herdr 移除该来源的元数据,并在可见窗格展示发生变化时发出展示变化事件。
+短期元数据用 `ttl_ms`。取值必须在 `1` 到 `86400000` 毫秒之间。想让元数据保留到被替换、清除或窗格或工作区关闭时,省略 `ttl_ms`。展示字段继续按来源过期;该次调用更新的每个令牌都有独立期限。令牌元数据不会在服务器重启后恢复。
-钩子可能乱序发送更新时,使用 `seq`。对同一 `source`,序号小于等于最后接受序号的上报会被 API 接受,但被窗格状态忽略。
+钩子可能乱序发送更新时,使用 `seq`。对同一 `source`,序号小于等于最后接受序号的上报会被 API 接受,但被窗格状态忽略。每个窗格或工作区在其生命周期内最多接受来自 32 个不同来源的带序号令牌上报;清除或过期不会释放这些来源名额。
## 事件订阅
@@ -508,8 +520,8 @@ Herdr 存有原生会话引用时,`pane.get`、`pane.list`、`agent.get` 和 `ag
第一个响应确认订阅。之后的行是推送的事件。
-工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.renamed`、`workspace.closed` 和 `workspace.focused`。工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。
-窗格事件订阅包括 `pane.created`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched` 和 `pane.agent_status_changed`。
+工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。
+窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched` 和 `pane.agent_status_changed`。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。
Worktree 事件订阅包括 `worktree.created`、`worktree.opened` 和 `worktree.removed`。Worktree 事件描述 Git 检出的生命周期。`worktree.created` 包含打开的 `workspace` 和创建的 `worktree`。`worktree.opened` 包含目标 `workspace`、打开的 `worktree` 和 `already_open`。`worktree.removed` 包含 `workspace_id`、被移除的 `worktree` 和 `forced`。
diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json
index 4c6d70e4..f84e6da1 100644
--- a/docs/next/website/src/data/config-reference.json
+++ b/docs/next/website/src/data/config-reference.json
@@ -669,6 +669,24 @@
]
},
{
+ "key": "ui.sidebar.agents.rows",
+ "type": "list of token rows",
+ "default": "[[\"state_icon\", \"workspace\", \"tab\"], [\"agent\"]]",
+ "description": "Default expanded Agent sidebar layout. Supports built-in tokens and $name metadata tokens; at most 16 rows and 16 tokens per row."
+ },
+ {
+ "key": "ui.sidebar.agents.rows_by_agent",
+ "type": "table of token rows",
+ "default": "{}",
+ "description": "Complete Agent-row overrides keyed by strict canonical agent id. Agents without an override use ui.sidebar.agents.rows."
+ },
+ {
+ "key": "ui.sidebar.spaces.rows",
+ "type": "list of token rows",
+ "default": "[[\"state_icon\", \"workspace\"], [\"branch\", \"git_status\"]]",
+ "description": "Expanded Space sidebar layout. Supports built-in tokens and $name metadata tokens; at most 16 rows and 16 tokens per row."
+ },
+ {
"key": "ui.accent",
"type": "color",
"default": "\"cyan\"",
diff --git a/scripts/seed_navigator_demo.sh b/scripts/seed_navigator_demo.sh
index 52ee3853..4b1c2f65 100755
--- a/scripts/seed_navigator_demo.sh
+++ b/scripts/seed_navigator_demo.sh
@@ -91,7 +91,10 @@ report() {
--source nav-seed \
--agent "$agent" \
--state "$state" \
- --custom-status "$status" \
+ --seq "$seq" >/dev/null
+ run pane report-metadata "$pane" \
+ --source nav-seed-display \
+ --token "summary=$status" \
--seq "$seq" >/dev/null
}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index b285eb3d..6f8fa9df 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -28,6 +28,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::WorkspaceFocus(_)
| Method::WorkspaceRename(_)
| Method::WorkspaceMove(_)
+ | Method::WorkspaceReportMetadata(_)
| Method::WorkspaceClose(_)
| Method::WorktreeCreate(_)
| Method::WorktreeOpen(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 1a51dfbc..4fb3d841 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -75,6 +75,8 @@ pub enum Method {
WorkspaceRename(WorkspaceRenameParams),
#[serde(rename = "workspace.move")]
WorkspaceMove(WorkspaceMoveParams),
+ #[serde(rename = "workspace.report_metadata")]
+ WorkspaceReportMetadata(WorkspaceReportMetadataParams),
#[serde(rename = "workspace.close")]
WorkspaceClose(WorkspaceTarget),
#[serde(rename = "worktree.list")]
diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs
index e5f319e0..969383c4 100644
--- a/src/api/schema/agents.rs
+++ b/src/api/schema/agents.rs
@@ -57,14 +57,19 @@ pub struct AgentInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub terminal_title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub terminal_title_stripped: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
pub agent_status: AgentStatus,
#[serde(default, skip_serializing_if = "super::is_false")]
pub screen_detection_skipped: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub state_labels: HashMap<String, String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ #[schemars(schema_with = "super::common::metadata_token_values_schema")]
+ pub tokens: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session: Option<AgentSessionInfo>,
pub workspace_id: String,
diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs
index 3d8376ed..e22fa1b3 100644
--- a/src/api/schema/common.rs
+++ b/src/api/schema/common.rs
@@ -1,5 +1,27 @@
use serde::{Deserialize, Serialize};
+pub(super) fn metadata_token_patch_schema(
+ _generator: &mut schemars::SchemaGenerator,
+) -> schemars::Schema {
+ schemars::json_schema!({
+ "type": "object",
+ "maxProperties": 16,
+ "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,32}$" },
+ "additionalProperties": { "type": ["string", "null"] }
+ })
+}
+
+pub(super) fn metadata_token_values_schema(
+ _generator: &mut schemars::SchemaGenerator,
+) -> schemars::Schema {
+ schemars::json_schema!({
+ "type": "object",
+ "maxProperties": 32,
+ "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,32}$" },
+ "additionalProperties": { "type": "string" }
+ })
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct EmptyParams {}
diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs
index 41b9907d..25ccc8ec 100644
--- a/src/api/schema/events.rs
+++ b/src/api/schema/events.rs
@@ -20,6 +20,8 @@ pub enum Subscription {
WorkspaceCreated {},
#[serde(rename = "workspace.updated")]
WorkspaceUpdated {},
+ #[serde(rename = "workspace.metadata_updated")]
+ WorkspaceMetadataUpdated {},
#[serde(rename = "workspace.renamed")]
WorkspaceRenamed {},
#[serde(rename = "workspace.moved")]
@@ -48,6 +50,8 @@ pub enum Subscription {
PaneCreated {},
#[serde(rename = "pane.closed")]
PaneClosed {},
+ #[serde(rename = "pane.updated")]
+ PaneUpdated {},
#[serde(rename = "pane.focused")]
PaneFocused {},
#[serde(rename = "pane.moved")]
@@ -188,6 +192,7 @@ pub enum EventMatch {
pub enum EventKind {
WorkspaceCreated,
WorkspaceUpdated,
+ WorkspaceMetadataUpdated,
WorkspaceClosed,
WorkspaceRenamed,
WorkspaceMoved,
@@ -202,6 +207,7 @@ pub enum EventKind {
TabFocused,
PaneCreated,
PaneClosed,
+ PaneUpdated,
PaneFocused,
PaneMoved,
PaneOutputChanged,
@@ -216,6 +222,7 @@ impl EventKind {
match self {
EventKind::WorkspaceCreated => "workspace.created",
EventKind::WorkspaceUpdated => "workspace.updated",
+ EventKind::WorkspaceMetadataUpdated => "workspace.metadata_updated",
EventKind::WorkspaceClosed => "workspace.closed",
EventKind::WorkspaceRenamed => "workspace.renamed",
EventKind::WorkspaceMoved => "workspace.moved",
@@ -230,6 +237,7 @@ impl EventKind {
EventKind::TabFocused => "tab.focused",
EventKind::PaneCreated => "pane.created",
EventKind::PaneClosed => "pane.closed",
+ EventKind::PaneUpdated => "pane.updated",
EventKind::PaneFocused => "pane.focused",
EventKind::PaneMoved => "pane.moved",
EventKind::PaneOutputChanged => "pane.output_changed",
@@ -245,6 +253,7 @@ impl EventKind {
pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceCreated,
EventKind::WorkspaceUpdated,
+ EventKind::WorkspaceMetadataUpdated,
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
EventKind::WorkspaceMoved,
@@ -259,6 +268,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::TabFocused,
EventKind::PaneCreated,
EventKind::PaneClosed,
+ EventKind::PaneUpdated,
EventKind::PaneFocused,
EventKind::PaneMoved,
EventKind::PaneOutputChanged,
@@ -336,6 +346,8 @@ mod known_event_name_tests {
let names = plugin_hook_event_names();
assert!(!names.contains(&"pane.output_changed"));
assert!(!names.contains(&"layout.updated"));
+ assert!(!names.contains(&"workspace.metadata_updated"));
+ assert!(!names.contains(&"pane.updated"));
assert!(names.contains(&"pane.moved"));
}
}
@@ -385,8 +397,6 @@ pub struct PaneAgentStatusChangedEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
@@ -410,6 +420,9 @@ pub enum EventData {
WorkspaceUpdated {
workspace: WorkspaceInfo,
},
+ WorkspaceMetadataUpdated {
+ workspace: WorkspaceInfo,
+ },
WorkspaceClosed {
workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -472,6 +485,9 @@ pub enum EventData {
pane_id: String,
workspace_id: String,
},
+ PaneUpdated {
+ pane: PaneInfo,
+ },
PaneFocused {
pane_id: String,
workspace_id: String,
@@ -515,8 +531,6 @@ pub enum EventData {
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
display_agent: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- custom_status: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
state_labels: HashMap<String, String>,
},
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
index 42a640ef..2e37e734 100644
--- a/src/api/schema/panes.rs
+++ b/src/api/schema/panes.rs
@@ -266,8 +266,6 @@ pub struct PaneReportAgentParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
@@ -302,21 +300,21 @@ pub struct PaneReportMetadataParams {
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub state_labels: HashMap<String, String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ #[schemars(schema_with = "super::common::metadata_token_patch_schema")]
+ pub tokens: HashMap<String, Option<String>>,
#[serde(default)]
pub clear_title: bool,
#[serde(default)]
pub clear_display_agent: bool,
#[serde(default)]
- pub clear_custom_status: bool,
- #[serde(default)]
pub clear_state_labels: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ #[schemars(range(min = 1, max = 86_400_000))]
pub ttl_ms: Option<u64>,
}
@@ -356,12 +354,17 @@ pub struct PaneInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub terminal_title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub terminal_title_stripped: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
pub agent_status: AgentStatus,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub state_labels: HashMap<String, String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ #[schemars(schema_with = "super::common::metadata_token_values_schema")]
+ pub tokens: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session: Option<AgentSessionInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 5f603a18..adc7e61c 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -596,6 +596,7 @@ fn worktree_request_and_response_round_trip() {
tab_count: 1,
active_tab_id: "w_1:1".into(),
agent_status: AgentStatus::Unknown,
+ tokens: HashMap::new(),
worktree: Some(WorkspaceWorktreeInfo {
repo_key: "/repo/herdr/.git".into(),
repo_name: "herdr".into(),
@@ -624,10 +625,12 @@ fn worktree_request_and_response_round_trip() {
label: None,
agent: None,
title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
display_agent: None,
agent_status: AgentStatus::Unknown,
- custom_status: None,
state_labels: HashMap::new(),
+ tokens: HashMap::new(),
agent_session: None,
scroll: None,
revision: 0,
@@ -679,6 +682,7 @@ fn worktree_lifecycle_events_round_trip() {
tab_count: 1,
active_tab_id: "w_2:1".into(),
agent_status: AgentStatus::Unknown,
+ tokens: HashMap::new(),
worktree: Some(WorkspaceWorktreeInfo {
repo_key: "/repo/herdr/.git".into(),
repo_name: "herdr".into(),
@@ -1032,10 +1036,12 @@ fn create_response_round_trips_with_root_pane() {
label: None,
agent: None,
title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
display_agent: None,
agent_status: AgentStatus::Unknown,
- custom_status: None,
state_labels: HashMap::new(),
+ tokens: HashMap::new(),
agent_session: None,
scroll: None,
revision: 0,
diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs
index 6934539c..a6177982 100644
--- a/src/api/schema/workspaces.rs
+++ b/src/api/schema/workspaces.rs
@@ -29,6 +29,19 @@ pub struct WorkspaceMoveParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct WorkspaceReportMetadataParams {
+ pub workspace_id: String,
+ pub source: String,
+ #[schemars(schema_with = "super::common::metadata_token_patch_schema")]
+ pub tokens: HashMap<String, Option<String>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[schemars(range(min = 1, max = 86_400_000))]
+ pub ttl_ms: Option<u64>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceInfo {
pub workspace_id: String,
pub number: usize,
@@ -38,6 +51,9 @@ pub struct WorkspaceInfo {
pub tab_count: usize,
pub active_tab_id: String,
pub agent_status: AgentStatus,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ #[schemars(schema_with = "super::common::metadata_token_values_schema")]
+ pub tokens: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<WorkspaceWorktreeInfo>,
}
diff --git a/src/api/server.rs b/src/api/server.rs
index 692e9388..9cef5ec1 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -322,6 +322,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::WorkspaceFocus(_) => "workspace.focus",
Method::WorkspaceRename(_) => "workspace.rename",
Method::WorkspaceMove(_) => "workspace.move",
+ Method::WorkspaceReportMetadata(_) => "workspace.report_metadata",
Method::WorkspaceClose(_) => "workspace.close",
Method::WorktreeList(_) => "worktree.list",
Method::WorktreeCreate(_) => "worktree.create",
@@ -811,10 +812,12 @@ mod tests {
label: None,
agent: Some("pi".into()),
title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
display_agent: None,
agent_status,
- custom_status: None,
state_labels: HashMap::new(),
+ tokens: HashMap::new(),
agent_session: None,
scroll: None,
revision: 0,
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index b3a369c2..531fb7bb 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -66,7 +66,6 @@ pub(super) struct ActiveScrollChangedSubscription {
struct PanePresentationSnapshot {
title: Option<String>,
display_agent: Option<String>,
- custom_status: Option<String>,
state_labels: std::collections::HashMap<String, String>,
}
@@ -75,7 +74,6 @@ impl PanePresentationSnapshot {
Self {
title: pane.title.clone(),
display_agent: pane.display_agent.clone(),
- custom_status: pane.custom_status.clone(),
state_labels: pane.state_labels.clone(),
}
}
@@ -83,13 +81,11 @@ impl PanePresentationSnapshot {
fn from_event(
title: &Option<String>,
display_agent: &Option<String>,
- custom_status: &Option<String>,
state_labels: &std::collections::HashMap<String, String>,
) -> Self {
Self {
title: title.clone(),
display_agent: display_agent.clone(),
- custom_status: custom_status.clone(),
state_labels: state_labels.clone(),
}
}
@@ -124,6 +120,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceUpdated,
last_sequence: 0,
})),
+ Subscription::WorkspaceMetadataUpdated {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorkspaceMetadataUpdated,
+ last_sequence: 0,
+ })),
Subscription::WorkspaceRenamed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceRenamed,
last_sequence: 0,
@@ -180,6 +180,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::PaneClosed,
last_sequence: 0,
})),
+ Subscription::PaneUpdated {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::PaneUpdated,
+ last_sequence: 0,
+ })),
Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneFocused,
last_sequence: 0,
@@ -261,7 +265,6 @@ impl ActiveSubscription {
agent: probe.agent,
title: probe.title,
display_agent: probe.display_agent,
- custom_status: probe.custom_status,
state_labels: probe.state_labels,
});
@@ -373,7 +376,6 @@ impl ActiveAgentStatusChangedSubscription {
agent,
title,
display_agent,
- custom_status,
state_labels,
} = event.data
else {
@@ -387,12 +389,8 @@ impl ActiveAgentStatusChangedSubscription {
}
saw_status_event = true;
- let current_presentation = PanePresentationSnapshot::from_event(
- &title,
- &display_agent,
- &custom_status,
- &state_labels,
- );
+ let current_presentation =
+ PanePresentationSnapshot::from_event(&title, &display_agent, &state_labels);
self.last_status = Some(agent_status);
self.last_presentation = Some(current_presentation);
if self
@@ -412,7 +410,6 @@ impl ActiveAgentStatusChangedSubscription {
agent,
title,
display_agent,
- custom_status,
state_labels,
}),
});
@@ -479,7 +476,6 @@ impl ActiveAgentStatusChangedSubscription {
agent: pane.agent,
title: pane.title,
display_agent: pane.display_agent,
- custom_status: pane.custom_status,
state_labels: pane.state_labels,
}),
})
@@ -613,7 +609,7 @@ mod tests {
use super::*;
use crate::api::schema::{AgentStatus, EventData, EventEnvelope, EventKind, PaneInfo};
- fn status_event(custom_status: Option<&str>) -> EventEnvelope {
+ fn presentation_event(title: Option<&str>) -> EventEnvelope {
EventEnvelope {
event: EventKind::PaneAgentStatusChanged,
data: EventData::PaneAgentStatusChanged {
@@ -621,9 +617,8 @@ mod tests {
workspace_id: "workspace_1".into(),
agent_status: AgentStatus::Working,
agent: Some("pi".into()),
- title: None,
+ title: title.map(str::to_string),
display_agent: None,
- custom_status: custom_status.map(str::to_string),
state_labels: HashMap::new(),
},
}
@@ -641,10 +636,12 @@ mod tests {
label: None,
agent: None,
title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
display_agent: None,
agent_status: AgentStatus::Unknown,
- custom_status: None,
state_labels: HashMap::new(),
+ tokens: HashMap::new(),
agent_session: None,
scroll,
revision: 0,
@@ -652,6 +649,28 @@ mod tests {
}
#[test]
+ fn workspace_metadata_subscription_uses_dedicated_event_kind() {
+ let event_hub = EventHub::default();
+ let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let subscription = ActiveSubscription::new(
+ Subscription::WorkspaceMetadataUpdated {},
+ "test",
+ 0,
+ &api_tx,
+ &event_hub,
+ )
+ .expect("workspace metadata subscription");
+
+ assert!(matches!(
+ subscription,
+ ActiveSubscription::Event(ActiveEventSubscription {
+ event_kind: EventKind::WorkspaceMetadataUpdated,
+ ..
+ })
+ ));
+ }
+
+ #[test]
fn scroll_subscription_emits_when_scroll_snapshot_changes() {
let at_bottom = PaneScrollInfo {
offset_from_bottom: 0,
@@ -695,7 +714,6 @@ mod tests {
last_presentation: Some(PanePresentationSnapshot {
title: None,
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
}),
last_sequence: event_hub.current_sequence(),
@@ -703,8 +721,8 @@ mod tests {
request_prefix: "test".into(),
};
- event_hub.push(status_event(Some("short lived")));
- event_hub.push(status_event(None));
+ event_hub.push(presentation_event(Some("short lived")));
+ event_hub.push(presentation_event(None));
let set_event = subscription
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
@@ -712,7 +730,7 @@ mod tests {
let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else {
panic!("wrong event data");
};
- assert_eq!(set_data.custom_status.as_deref(), Some("short lived"));
+ assert_eq!(set_data.title.as_deref(), Some("short lived"));
let expiry_event = subscription
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
@@ -720,7 +738,7 @@ mod tests {
let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else {
panic!("wrong event data");
};
- assert_eq!(expiry_data.custom_status, None);
+ assert_eq!(expiry_data.title, None);
}
#[test]
@@ -733,7 +751,6 @@ mod tests {
last_presentation: Some(PanePresentationSnapshot {
title: None,
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
}),
last_sequence: event_hub.current_sequence(),
@@ -744,14 +761,13 @@ mod tests {
agent: Some("pi".into()),
title: None,
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
}),
request_prefix: "test".into(),
};
- event_hub.push(status_event(Some("short lived")));
- event_hub.push(status_event(None));
+ event_hub.push(presentation_event(Some("short lived")));
+ event_hub.push(presentation_event(None));
let set_event = subscription
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
@@ -759,7 +775,7 @@ mod tests {
let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else {
panic!("wrong event data");
};
- assert_eq!(set_data.custom_status.as_deref(), Some("short lived"));
+ assert_eq!(set_data.title.as_deref(), Some("short lived"));
let expiry_event = subscription
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
@@ -767,7 +783,7 @@ mod tests {
let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else {
panic!("wrong event data");
};
- assert_eq!(expiry_data.custom_status, None);
+ assert_eq!(expiry_data.title, None);
}
#[test]
@@ -778,9 +794,8 @@ mod tests {
status_filter: Some(AgentStatus::Working),
last_status: Some(AgentStatus::Working),
last_presentation: Some(PanePresentationSnapshot {
- title: None,
+ title: Some("short lived".into()),
display_agent: None,
- custom_status: Some("short lived".into()),
state_labels: HashMap::new(),
}),
last_sequence: event_hub.current_sequence(),
@@ -789,15 +804,14 @@ mod tests {
workspace_id: "workspace_1".into(),
agent_status: AgentStatus::Working,
agent: Some("pi".into()),
- title: None,
+ title: Some("short lived".into()),
display_agent: None,
- custom_status: Some("short lived".into()),
state_labels: HashMap::new(),
}),
request_prefix: "test".into(),
};
- event_hub.push(status_event(Some("short lived")));
+ event_hub.push(presentation_event(Some("short lived")));
let event = subscription
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
@@ -805,7 +819,7 @@ mod tests {
let SubscriptionEventData::PaneAgentStatusChanged(data) = event.data else {
panic!("wrong event data");
};
- assert_eq!(data.custom_status.as_deref(), Some("short lived"));
+ assert_eq!(data.title.as_deref(), Some("short lived"));
assert!(subscription.initial_event.is_none());
}
}
diff --git a/src/api/wait.rs b/src/api/wait.rs
index e70472c3..17ffb992 100644
--- a/src/api/wait.rs
+++ b/src/api/wait.rs
@@ -231,7 +231,6 @@ fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String {
agent: data.agent,
title: data.title,
display_agent: data.display_agent,
- custom_status: data.custom_status,
state_labels: data.state_labels,
},
},
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 5c263e7e..7cf43e79 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -537,15 +537,13 @@ impl AppState {
.and_then(|terminal| terminal.agent_name.as_deref())
.or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()))
});
- let custom_status = terminal.and_then(|terminal| terminal.effective_custom_status());
let state = terminal
.map(|terminal| terminal.state)
.unwrap_or(AgentState::Unknown);
let status_label = terminal
.map(|terminal| terminal.effective_presentation().state_labels)
.and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned());
- let status = custom_status
- .or(status_label)
+ let status = status_label
.or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string()));
let meta = match (agent_label, status.as_deref()) {
(Some(agent_label), Some(status)) => format!("{agent_label} · {status}"),
@@ -893,6 +891,16 @@ impl AppState {
self.terminals
.values()
.filter_map(|terminal| terminal.next_agent_metadata_expiry())
+ .chain(
+ self.terminals
+ .values()
+ .filter_map(|terminal| terminal.metadata_tokens.next_expiry()),
+ )
+ .chain(
+ self.workspaces
+ .iter()
+ .filter_map(|workspace| workspace.metadata_tokens.next_expiry()),
+ )
.min()
}
@@ -946,6 +954,48 @@ impl AppState {
.collect()
}
+ pub(crate) fn expire_metadata_tokens(
+ &mut self,
+ now: std::time::Instant,
+ ) -> (Vec<(usize, PaneId)>, Vec<usize>) {
+ let pane_terminals = self
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, workspace)| {
+ workspace.tabs.iter().flat_map(move |tab| {
+ tab.layout
+ .pane_ids()
+ .into_iter()
+ .filter_map(move |pane_id| {
+ workspace
+ .pane_state(pane_id)
+ .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone()))
+ })
+ })
+ })
+ .collect::<Vec<_>>();
+ let changed_panes = pane_terminals
+ .into_iter()
+ .filter_map(|(ws_idx, pane_id, terminal_id)| {
+ let terminal = self.terminals.get_mut(&terminal_id)?;
+ terminal.metadata_tokens.expire_at(now).then(|| {
+ terminal.revision = terminal.revision.saturating_add(1);
+ (ws_idx, pane_id)
+ })
+ })
+ .collect();
+ let changed_workspaces = self
+ .workspaces
+ .iter_mut()
+ .enumerate()
+ .filter_map(|(ws_idx, workspace)| {
+ workspace.metadata_tokens.expire_at(now).then_some(ws_idx)
+ })
+ .collect();
+ (changed_panes, changed_workspaces)
+ }
+
pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool {
let Some(active_ws_idx) = self.active else {
return false;
@@ -1341,21 +1391,12 @@ impl AppState {
self.view.sidebar_rect,
self.sidebar_section_split,
);
- let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area);
- let visible = metrics.viewport_rows;
- if visible == 0 {
- return;
- }
-
- if idx < self.agent_panel_scroll {
- self.agent_panel_scroll = idx;
- } else if idx >= self.agent_panel_scroll.saturating_add(visible) {
- self.agent_panel_scroll = idx.saturating_add(1).saturating_sub(visible);
- }
-
- let max_scroll =
- crate::ui::agent_panel_scroll_metrics(self, detail_area).max_offset_from_bottom;
- self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll);
+ self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target(
+ self,
+ detail_area,
+ self.agent_panel_scroll,
+ idx,
+ );
}
pub(crate) fn terminal_ids_for_workspace(
@@ -2559,7 +2600,6 @@ impl AppState {
agent_label,
state,
message,
- custom_status,
seq,
session_ref,
} => {
@@ -2576,7 +2616,6 @@ impl AppState {
agent_label,
state,
message,
- custom_status,
session_ref,
seq,
)
@@ -2611,11 +2650,9 @@ impl AppState {
applies_to_source,
title,
display_agent,
- custom_status,
state_labels,
clear_title,
clear_display_agent,
- clear_custom_status,
clear_state_labels,
seq,
ttl,
@@ -2627,11 +2664,9 @@ impl AppState {
applies_to_source,
title,
display_agent,
- custom_status,
state_labels,
clear_title,
clear_display_agent,
- clear_custom_status,
clear_state_labels,
ttl,
seq,
@@ -4578,7 +4613,6 @@ mod tests {
agent_label: "hermes".into(),
state: AgentState::Blocked,
message: None,
- custom_status: None,
seq: None,
session_ref: None,
});
@@ -4617,7 +4651,6 @@ mod tests {
agent_label: "codex".into(),
state: AgentState::Working,
message: None,
- custom_status: None,
seq: Some(1),
session_ref: None,
});
@@ -4666,7 +4699,6 @@ mod tests {
agent_label: "claude".into(),
state: AgentState::Blocked,
message: None,
- custom_status: None,
seq: Some(1),
session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"),
});
@@ -4749,7 +4781,6 @@ mod tests {
agent_label: "devin".into(),
state: AgentState::Working,
message: None,
- custom_status: None,
seq: Some(1),
session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"),
});
@@ -4774,7 +4805,6 @@ mod tests {
agent_label: "pi".into(),
state: AgentState::Working,
message: None,
- custom_status: None,
seq: Some(20),
session_ref: crate::agent_resume::AgentSessionRef::path(first_session),
});
@@ -4787,7 +4817,6 @@ mod tests {
agent_label: "pi".into(),
state: AgentState::Working,
message: None,
- custom_status: None,
seq: Some(21),
session_ref: crate::agent_resume::AgentSessionRef::path(second_session),
});
diff --git a/src/app/agents.rs b/src/app/agents.rs
index bec375b9..83f97bd4 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -422,11 +422,13 @@ impl App {
name: terminal.agent_name.clone(),
agent: pane.agent,
title: pane.title,
+ terminal_title: pane.terminal_title,
+ terminal_title_stripped: pane.terminal_title_stripped,
display_agent: pane.display_agent,
agent_status: pane.agent_status,
screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(),
- custom_status: pane.custom_status,
state_labels: pane.state_labels,
+ tokens: pane.tokens,
agent_session: pane.agent_session,
workspace_id: pane.workspace_id,
tab_id: pane.tab_id,
diff --git a/src/app/api.rs b/src/app/api.rs
index 9ebbbe56..c013d271 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -579,7 +579,6 @@ impl App {
agent: update.agent_label.clone(),
title: presentation.title,
display_agent: presentation.display_agent,
- custom_status: presentation.custom_status,
state_labels: presentation.state_labels,
},
});
@@ -745,6 +744,26 @@ impl App {
self.event_hub.push(event);
}
+ pub(crate) fn emit_pane_updated(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) {
+ if let Some(pane) = self.pane_info(ws_idx, pane_id) {
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneUpdated,
+ data: crate::api::schema::EventData::PaneUpdated { pane },
+ });
+ }
+ }
+
+ pub(crate) fn emit_workspace_token_updated(&mut self, ws_idx: usize) {
+ // Token updates bypass plugin hooks so a hook cannot refresh its own
+ // token and recursively trigger workspace.updated.
+ self.event_hub.push(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::WorkspaceMetadataUpdated,
+ data: crate::api::schema::EventData::WorkspaceMetadataUpdated {
+ workspace: self.workspace_info(ws_idx),
+ },
+ });
+ }
+
pub(crate) fn sync_focus_events(&mut self) {
let current_focus = self.state.active.and_then(|idx| {
self.state
@@ -816,6 +835,7 @@ impl App {
&mut self,
request: crate::api::schema::Request,
) -> String {
+ self.sync_terminal_titles();
use crate::api::schema::{
ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse,
};
@@ -908,6 +928,9 @@ impl App {
Method::WorkspaceMove(params) => {
return self.handle_workspace_move(request.id, params);
}
+ Method::WorkspaceReportMetadata(params) => {
+ return self.handle_workspace_report_metadata(request.id, params);
+ }
Method::WorkspaceClose(target) => {
return self.handle_workspace_close(request.id, target)
}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 58dbeb19..17569682 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -20,15 +20,14 @@ use crate::app::Mode;
use crate::layout::{find_in_direction, NavDirection, PaneId};
use super::super::api_helpers::{
- detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status,
- normalize_reported_agent_label,
+ detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source,
+ normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label,
+ MAX_METADATA_TOKEN_KEYS_PER_RESOURCE,
};
+#[cfg(test)]
+use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS};
use super::responses::{encode_error, encode_success};
-const METADATA_SOURCE_MAX_CHARS: usize = 80;
-const METADATA_TTL_MIN_MS: u64 = 1;
-const METADATA_TTL_MAX_MS: u64 = 86_400_000;
-
impl App {
pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String {
let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() {
@@ -1237,7 +1236,6 @@ impl App {
agent_label,
state: detect_state_from_api(params.state),
message: params.message,
- custom_status: normalize_custom_status(params.custom_status),
seq: params.seq,
});
@@ -1279,7 +1277,7 @@ impl App {
id: String,
params: PaneReportMetadataParams,
) -> String {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let agent_label = match params.agent.as_deref() {
@@ -1295,15 +1293,21 @@ impl App {
};
let raw_title_set = params.title.is_some();
let raw_display_agent_set = params.display_agent.is_some();
- let raw_custom_status_set = params.custom_status.is_some();
let raw_state_labels_set = !params.state_labels.is_empty();
+ let tokens = if params.tokens.is_empty() {
+ None
+ } else {
+ match normalize_metadata_tokens(params.tokens) {
+ Ok(tokens) => Some(tokens),
+ Err(message) => return encode_error(id, "invalid_metadata_token", message),
+ }
+ };
let ttl = match normalize_metadata_ttl(params.ttl_ms) {
Ok(ttl) => ttl,
Err(message) => return encode_error(id, "invalid_metadata_ttl", message),
};
let title = normalize_presentation_text(params.title);
let display_agent = normalize_presentation_text(params.display_agent);
- let custom_status = normalize_custom_status(params.custom_status);
let applies_to_source = match params.applies_to_source {
Some(applies_to_source) => match normalize_metadata_source(applies_to_source) {
Ok(applies_to_source) => Some(applies_to_source),
@@ -1323,7 +1327,6 @@ impl App {
};
if raw_title_set && params.clear_title
|| raw_display_agent_set && params.clear_display_agent
- || raw_custom_status_set && params.clear_custom_status
|| raw_state_labels_set && params.clear_state_labels
{
return encode_error(
@@ -1334,11 +1337,10 @@ impl App {
}
if title.is_none()
&& display_agent.is_none()
- && custom_status.is_none()
&& state_labels.is_empty()
+ && tokens.is_none()
&& !params.clear_title
&& !params.clear_display_agent
- && !params.clear_custom_status
&& !params.clear_state_labels
{
return encode_error(
@@ -1347,23 +1349,85 @@ impl App {
"missing metadata field to set or clear",
);
}
- self.handle_internal_event(crate::events::AppEvent::HookMetadataReported {
- pane_id,
- source,
- agent_label,
- applies_to_source,
- title,
- display_agent,
- custom_status,
- state_labels,
- clear_title: params.clear_title,
- clear_display_agent: params.clear_display_agent,
- clear_custom_status: params.clear_custom_status,
- clear_state_labels: params.clear_state_labels,
- seq: params.seq,
- ttl,
+ let presentation_requested = title.is_some()
+ || display_agent.is_some()
+ || !state_labels.is_empty()
+ || params.clear_title
+ || params.clear_display_agent
+ || params.clear_state_labels;
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|workspace| workspace.pane_state(pane_id))
+ .map(|pane| pane.attached_terminal_id.clone())
+ else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) {
+ return encode_success(id, ResponseResult::Ok {});
+ }
+ if let Some(tokens) = tokens.as_ref() {
+ if terminal.metadata_tokens.key_count_after_patch(tokens)
+ > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE
+ {
+ return encode_error(
+ id,
+ "metadata_token_limit",
+ format!(
+ "pane metadata may contain at most {MAX_METADATA_TOKEN_KEYS_PER_RESOURCE} tokens"
+ ),
+ );
+ }
+ }
+ match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) {
+ Ok(true) => {}
+ Ok(false) => return encode_success(id, ResponseResult::Ok {}),
+ Err(()) => {
+ return encode_error(
+ id,
+ "metadata_sequence_source_limit",
+ format!(
+ "pane metadata may track at most {} sequenced sources",
+ crate::metadata_tokens::MAX_SEQUENCE_SOURCES
+ ),
+ );
+ }
+ }
+ let token_changed = tokens.is_some_and(|tokens| {
+ let changed = terminal
+ .metadata_tokens
+ .patch(tokens, ttl, std::time::Instant::now());
+ if changed {
+ terminal.revision = terminal.revision.saturating_add(1);
+ }
+ changed
});
+ if presentation_requested {
+ self.handle_internal_event(crate::events::AppEvent::HookMetadataReported {
+ pane_id,
+ source,
+ agent_label,
+ applies_to_source,
+ title,
+ display_agent,
+ state_labels,
+ clear_title: params.clear_title,
+ clear_display_agent: params.clear_display_agent,
+ clear_state_labels: params.clear_state_labels,
+ seq: None,
+ ttl,
+ });
+ }
+ if token_changed {
+ self.sync_agent_metadata_deadline();
+ self.emit_pane_updated(ws_idx, pane_id);
+ }
+
encode_success(id, ResponseResult::Ok {})
}
@@ -1551,41 +1615,6 @@ impl App {
}
}
-fn normalize_metadata_source(value: String) -> Result<String, &'static str> {
- let value = value.trim();
- if value.is_empty() {
- return Err("metadata source must not be empty");
- }
- if value.chars().count() > METADATA_SOURCE_MAX_CHARS {
- return Err("metadata source must be 80 characters or fewer");
- }
- if !value.chars().all(metadata_source_char_is_valid) {
- return Err(
- "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen",
- );
- }
- Ok(value.to_string())
-}
-
-fn metadata_source_char_is_valid(ch: char) -> bool {
- ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')
-}
-
-fn normalize_metadata_ttl(
- ttl_ms: Option<u64>,
-) -> Result<Option<std::time::Duration>, &'static str> {
- let Some(ttl_ms) = ttl_ms else {
- return Ok(None);
- };
- if ttl_ms < METADATA_TTL_MIN_MS {
- return Err("metadata ttl_ms must be at least 1");
- }
- if ttl_ms > METADATA_TTL_MAX_MS {
- return Err("metadata ttl_ms must be 86400000 or less");
- }
- Ok(Some(std::time::Duration::from_millis(ttl_ms)))
-}
-
fn normalize_presentation_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
let normalized: String = trimmed
@@ -1850,6 +1879,7 @@ mod tests {
use crate::{
api::schema::{ErrorResponse, SplitDirection, SuccessResponse},
config::Config,
+ detect::{Agent, AgentState},
workspace::Workspace,
};
@@ -1902,13 +1932,12 @@ mod tests {
source: "user:metadata.test-1".into(),
agent: None,
applies_to_source: None,
- title: None,
+ title: Some("activity".into()),
display_agent: None,
- custom_status: Some("activity".into()),
state_labels: std::collections::HashMap::new(),
+ tokens: std::collections::HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
seq: None,
ttl_ms: None,
@@ -3584,6 +3613,116 @@ mod tests {
}
#[test]
+ fn pane_metadata_tokens_patch_and_clear_through_dispatcher() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ for (tokens, expected) in [
+ (
+ std::collections::HashMap::from([
+ ("summary".into(), Some("reviewing auth".into())),
+ ("model".into(), Some("opus".into())),
+ ]),
+ std::collections::HashMap::from([
+ ("summary".into(), "reviewing auth".into()),
+ ("model".into(), "opus".into()),
+ ]),
+ ),
+ (
+ std::collections::HashMap::from([
+ ("summary".into(), Some("done".into())),
+ ("model".into(), None),
+ ]),
+ std::collections::HashMap::from([("summary".into(), "done".into())]),
+ ),
+ ] {
+ let mut params = metadata_params(pane_id.clone());
+ params.title = None;
+ params.tokens = tokens;
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "set".into(),
+ method: crate::api::schema::Method::PaneReportMetadata(params),
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.result, ResponseResult::Ok {});
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "get".into(),
+ method: crate::api::schema::Method::PaneGet(PaneTarget {
+ pane_id: pane_id.clone(),
+ }),
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneInfo { pane } = success.result else {
+ panic!("expected pane info");
+ };
+ assert_eq!(pane.tokens, expected);
+ }
+ let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap();
+ let terminal_id = app.state.workspaces[0]
+ .pane_state(internal_pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ assert!(app.state.terminals[&terminal_id].agent_metadata.is_empty());
+ }
+
+ #[test]
+ fn pane_tokens_are_independent_from_presentation_guards() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap();
+ let terminal_id = app.state.workspaces[0]
+ .pane_state(internal_pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state(Some(Agent::Claude), AgentState::Working);
+ let mut params = metadata_params(pane_id);
+ params.title = None;
+ params.agent = Some("codex".into());
+ params.tokens =
+ std::collections::HashMap::from([("summary".into(), Some("global".into()))]);
+
+ let response = app.handle_pane_report_metadata("guarded".into(), params);
+
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(
+ app.state.terminals[&terminal_id].metadata_tokens.values(),
+ std::collections::HashMap::from([("summary".into(), "global".into())])
+ );
+ }
+
+ #[test]
+ fn pane_metadata_uses_one_sequence_for_presentation_and_tokens() {
+ let (mut app, pane_id) = app_with_test_workspace();
+ let mut presentation = metadata_params(pane_id.clone());
+ presentation.seq = Some(10);
+ let response = app.handle_pane_report_metadata("presentation".into(), presentation);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let mut stale_token = metadata_params(pane_id.clone());
+ stale_token.title = None;
+ stale_token.tokens =
+ std::collections::HashMap::from([("summary".into(), Some("stale".into()))]);
+ stale_token.seq = Some(9);
+ let response = app.handle_pane_report_metadata("stale".into(), stale_token);
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+
+ let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap();
+ let terminal_id = app.state.workspaces[0]
+ .pane_state(internal_pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ assert!(app.state.terminals[&terminal_id]
+ .metadata_tokens
+ .values()
+ .is_empty());
+ }
+
+ #[test]
fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() {
let (mut app, pane_id) = app_with_test_workspace();
let mut params = metadata_params(pane_id);
diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs
index ead5c8e2..16d2fdb1 100644
--- a/src/app/api/plugins/context.rs
+++ b/src/app/api/plugins/context.rs
@@ -44,6 +44,7 @@ impl App {
match &event.data {
EventData::WorkspaceCreated { workspace }
| EventData::WorkspaceUpdated { workspace }
+ | EventData::WorkspaceMetadataUpdated { workspace }
| EventData::WorktreeCreated { workspace, .. }
| EventData::WorktreeOpened { workspace, .. } => {
self.plugin_context_for_workspace_info(workspace, correlation_id)
@@ -132,7 +133,7 @@ impl App {
context.tab_id = Some(layout.tab_id.clone());
context
}),
- EventData::PaneCreated { pane } => {
+ EventData::PaneCreated { pane } | EventData::PaneUpdated { pane } => {
self.plugin_context_for_pane_info(pane, correlation_id)
}
EventData::PaneMoved { pane, .. } => {
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index b77b4715..1e0ec7d8 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -2369,7 +2369,6 @@ action = "missing"
agent: "codex".into(),
state: crate::api::schema::PaneAgentState::Working,
message: None,
- custom_status: None,
seq: None,
agent_session_id: None,
agent_session_path: None,
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index 8899ec44..b7260c17 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -2,10 +2,11 @@ use std::path::PathBuf;
use crate::api::schema::{
EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams,
- WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget,
+ WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget,
};
use crate::app::App;
+use super::super::api_helpers::{normalize_metadata_source, normalize_metadata_ttl};
use super::responses::{encode_error, encode_success};
impl App {
@@ -153,6 +154,76 @@ impl App {
encode_success(id, ResponseResult::WorkspaceList { workspaces })
}
+ pub(super) fn handle_workspace_report_metadata(
+ &mut self,
+ id: String,
+ params: WorkspaceReportMetadataParams,
+ ) -> String {
+ let Some(index) = self.parse_workspace_id(&params.workspace_id) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ let source = match normalize_metadata_source(params.source) {
+ Ok(source) => source,
+ Err(message) => return encode_error(id, "invalid_metadata_source", message),
+ };
+ let ttl = match normalize_metadata_ttl(params.ttl_ms) {
+ Ok(ttl) => ttl,
+ Err(message) => return encode_error(id, "invalid_metadata_ttl", message),
+ };
+ let tokens = match super::super::api_helpers::normalize_metadata_tokens(params.tokens) {
+ Ok(tokens) => tokens,
+ Err(message) => return encode_error(id, "invalid_metadata_token", message),
+ };
+ let Some(workspace) = self.state.workspaces.get_mut(index) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ if !crate::metadata_tokens::sequence_is_fresh(
+ &workspace.metadata_token_sequences,
+ &source,
+ params.seq,
+ ) {
+ return encode_success(id, ResponseResult::Ok {});
+ }
+ if workspace.metadata_tokens.key_count_after_patch(&tokens)
+ > super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE
+ {
+ return encode_error(
+ id,
+ "metadata_token_limit",
+ format!(
+ "workspace metadata may contain at most {} tokens",
+ super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE
+ ),
+ );
+ }
+ match crate::metadata_tokens::accept_sequence(
+ &mut workspace.metadata_token_sequences,
+ &source,
+ params.seq,
+ ) {
+ Ok(true) => {}
+ Ok(false) => return encode_success(id, ResponseResult::Ok {}),
+ Err(()) => {
+ return encode_error(
+ id,
+ "metadata_sequence_source_limit",
+ format!(
+ "workspace metadata may track at most {} sequenced sources",
+ crate::metadata_tokens::MAX_SEQUENCE_SOURCES
+ ),
+ );
+ }
+ }
+ let changed = workspace
+ .metadata_tokens
+ .patch(tokens, ttl, std::time::Instant::now());
+ if changed {
+ self.sync_agent_metadata_deadline();
+ self.emit_workspace_token_updated(index);
+ }
+ encode_success(id, ResponseResult::Ok {})
+ }
+
pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String {
let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
return workspace_not_found(id, &target.workspace_id);
@@ -364,6 +435,90 @@ mod tests {
}
#[test]
+ fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ let workspace_id = app.public_workspace_id(0);
+
+ for (tokens, expected) in [
+ (
+ std::collections::HashMap::from([
+ ("summary".into(), Some("reviewing auth".into())),
+ ("jj_status".into(), Some("2 changes".into())),
+ ]),
+ std::collections::HashMap::from([
+ ("summary".into(), "reviewing auth".into()),
+ ("jj_status".into(), "2 changes".into()),
+ ]),
+ ),
+ (
+ std::collections::HashMap::from([
+ ("summary".into(), Some("done".into())),
+ ("jj_status".into(), None),
+ ]),
+ std::collections::HashMap::from([("summary".into(), "done".into())]),
+ ),
+ ] {
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorkspaceReportMetadata(
+ WorkspaceReportMetadataParams {
+ workspace_id: workspace_id.clone(),
+ source: "user:test".into(),
+ tokens,
+ seq: None,
+ ttl_ms: None,
+ },
+ ),
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.result, ResponseResult::Ok {});
+ assert_eq!(app.workspace_info(0).tokens, expected);
+ }
+
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!(
+ &event.data,
+ EventData::WorkspaceMetadataUpdated { workspace }
+ if workspace.tokens.get("summary").map(String::as_str) == Some("done")
+ && !workspace.tokens.contains_key("jj_status")
+ )));
+ }
+
+ #[test]
+ fn workspace_token_ttl_expires_through_runtime_and_emits_update() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ let workspace_id = app.public_workspace_id(0);
+ let response = app.handle_workspace_report_metadata(
+ "req".into(),
+ WorkspaceReportMetadataParams {
+ workspace_id,
+ source: "user:test".into(),
+ tokens: std::collections::HashMap::from([(
+ "summary".into(),
+ Some("temporary".into()),
+ )]),
+ seq: None,
+ ttl_ms: Some(1),
+ },
+ );
+ let _: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let deadline = app.agent_metadata_deadline.expect("token deadline");
+
+ app.expire_metadata_at(deadline, deadline);
+
+ assert!(app.workspace_info(0).tokens.is_empty());
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!(
+ &event.data,
+ EventData::WorkspaceMetadataUpdated { workspace } if workspace.tokens.is_empty()
+ )));
+ }
+
+ #[test]
fn api_workspace_move_reorders_workspaces() {
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs
index c07a431d..c0a98357 100644
--- a/src/app/api_helpers.rs
+++ b/src/app/api_helpers.rs
@@ -83,11 +83,118 @@ pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> {
Some(trimmed.to_string())
}
-pub(super) fn normalize_custom_status(status: Option<String>) -> Option<String> {
- let trimmed = status?.trim().to_string();
- let mut normalized = String::new();
- for ch in trimmed.chars().filter(|ch| !ch.is_control()).take(32) {
- normalized.push(ch);
+pub(super) const METADATA_TTL_MAX_MS: u64 = 86_400_000;
+pub(super) const METADATA_SOURCE_MAX_CHARS: usize = 80;
+const METADATA_TTL_MIN_MS: u64 = 1;
+const MAX_METADATA_TOKEN_KEYS_PER_REQUEST: usize = 16;
+pub(super) const MAX_METADATA_TOKEN_KEYS_PER_RESOURCE: usize = 32;
+const MAX_METADATA_TOKEN_KEY_LEN: usize = 32;
+const MAX_METADATA_TOKEN_VALUE_LEN: usize = 80;
+
+pub(super) fn normalize_metadata_source(value: String) -> Result<String, &'static str> {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err("metadata source must not be empty");
+ }
+ if value.chars().count() > METADATA_SOURCE_MAX_CHARS {
+ return Err("metadata source must be 80 characters or fewer");
+ }
+ if !value
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-'))
+ {
+ return Err(
+ "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen",
+ );
+ }
+ Ok(value.to_string())
+}
+
+pub(super) fn normalize_metadata_ttl(
+ ttl_ms: Option<u64>,
+) -> Result<Option<std::time::Duration>, &'static str> {
+ let Some(ttl_ms) = ttl_ms else {
+ return Ok(None);
+ };
+ if ttl_ms < METADATA_TTL_MIN_MS {
+ return Err("metadata ttl_ms must be at least 1");
+ }
+ if ttl_ms > METADATA_TTL_MAX_MS {
+ return Err("metadata ttl_ms must be 86400000 or less");
+ }
+ Ok(Some(std::time::Duration::from_millis(ttl_ms)))
+}
+
+pub(super) fn normalize_metadata_tokens(
+ tokens: std::collections::HashMap<String, Option<String>>,
+) -> Result<std::collections::HashMap<String, Option<String>>, String> {
+ if tokens.is_empty() {
+ return Err("missing token to set or clear".into());
+ }
+ if tokens.len() > MAX_METADATA_TOKEN_KEYS_PER_REQUEST {
+ return Err(format!(
+ "a metadata report may update at most {MAX_METADATA_TOKEN_KEYS_PER_REQUEST} tokens"
+ ));
+ }
+
+ tokens
+ .into_iter()
+ .map(|(key, value)| {
+ if key.is_empty()
+ || key.len() > MAX_METADATA_TOKEN_KEY_LEN
+ || !key
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
+ {
+ return Err(format!("invalid metadata token key: {key}"));
+ }
+ let value = value.and_then(|value| {
+ let normalized = value
+ .trim()
+ .chars()
+ .filter(|ch| !ch.is_control())
+ .take(MAX_METADATA_TOKEN_VALUE_LEN)
+ .collect::<String>();
+ (!normalized.trim().is_empty()).then(|| normalized.trim().to_string())
+ });
+ Ok((key, value))
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod metadata_token_tests {
+ use super::*;
+
+ #[test]
+ fn token_normalization_sanitizes_values_and_turns_empty_into_clear() {
+ let tokens = normalize_metadata_tokens(std::collections::HashMap::from([
+ ("summary".into(), Some(" review\nready ".into())),
+ ("empty".into(), Some(" \n ".into())),
+ ("clear".into(), None),
+ ]))
+ .unwrap();
+
+ assert_eq!(tokens["summary"].as_deref(), Some("reviewready"));
+ assert_eq!(tokens["empty"], None);
+ assert_eq!(tokens["clear"], None);
+ }
+
+ #[test]
+ fn token_normalization_rejects_invalid_or_unbounded_keys() {
+ for key in [
+ "bad.name".to_string(),
+ "x".repeat(MAX_METADATA_TOKEN_KEY_LEN + 1),
+ ] {
+ assert!(normalize_metadata_tokens(std::collections::HashMap::from([(
+ key,
+ Some("value".into()),
+ )]))
+ .is_err());
+ }
+ let too_many = (0..=MAX_METADATA_TOKEN_KEYS_PER_REQUEST)
+ .map(|index| (format!("key{index}"), Some("value".into())))
+ .collect();
+ assert!(normalize_metadata_tokens(too_many).is_err());
}
- (!normalized.trim().is_empty()).then(|| normalized.trim().to_string())
}
diff --git a/src/app/creation.rs b/src/app/creation.rs
index 734c57aa..0277ba10 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -408,10 +408,12 @@ impl App {
label: terminal.manual_label.clone(),
agent: terminal.effective_agent_label().map(str::to_string),
title: presentation.title,
+ terminal_title: terminal.terminal_title.clone(),
+ terminal_title_stripped: terminal.terminal_title_stripped(),
display_agent: presentation.display_agent,
agent_status: pane_agent_status(terminal.state, pane.seen),
- custom_status: presentation.custom_status,
state_labels: presentation.state_labels,
+ tokens: terminal.metadata_tokens.values(),
agent_session: terminal_agent_session_info(terminal),
scroll,
revision: terminal.revision,
@@ -452,6 +454,7 @@ impl App {
crate::workspace::public_tab_id_for_number(&ws.id, ws.active_tab + 1)
}),
agent_status: pane_agent_status(agg_state, seen),
+ tokens: ws.metadata_tokens.values(),
worktree: ws
.worktree_space()
.map(|space| crate::api::schema::WorkspaceWorktreeInfo {
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 32ad14b3..acbdfb9a 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -443,7 +443,7 @@ impl AppState {
detail_area,
crate::ui::should_show_scrollbar(metrics),
);
- if body.height < 2 || row < body.y || row >= body.y + body.height {
+ if body.height == 0 || row < body.y || row >= body.y + body.height {
return None;
}
@@ -452,13 +452,14 @@ impl AppState {
.into_iter()
.skip(self.agent_panel_scroll)
{
- if row_y.saturating_add(1) >= body.y + body.height {
+ let height = crate::ui::agent_entry_height_in_body(self, &detail, body.height);
+ if row_y.saturating_add(height) > body.y + body.height {
break;
}
- if row == row_y || row == row_y + 1 {
+ if row >= row_y && row < row_y.saturating_add(height) {
return Some((detail.ws_idx, detail.tab_idx, detail.pane_id));
}
- row_y = row_y.saturating_add(2);
+ row_y = row_y.saturating_add(height);
if row_y < body.y + body.height {
row_y = row_y.saturating_add(1);
}
@@ -692,6 +693,53 @@ mod tests {
}
#[test]
+ fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() {
+ let mut app = app_for_mouse_test();
+ let first = Workspace::test_new("one");
+ let first_pane = first.tabs[0].root_pane;
+ let second = Workspace::test_new("two");
+ let second_pane = second.tabs[0].root_pane;
+ app.state.workspaces = vec![first, second];
+ app.state.ensure_test_terminals();
+ for (ws_idx, pane_id, agent) in
+ [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)]
+ {
+ let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(agent);
+ }
+ app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
+ app.state.sidebar_agents.rows_by_agent.insert(
+ "claude".into(),
+ vec![
+ vec![crate::config::AgentSidebarToken::Agent],
+ vec![crate::config::AgentSidebarToken::Workspace],
+ ],
+ );
+ let detail_area = app.state.agent_panel_rect();
+ let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area);
+ let body = crate::ui::agent_panel_body_rect(
+ detail_area,
+ crate::ui::should_show_scrollbar(metrics),
+ );
+
+ assert_eq!(
+ app.state.agent_detail_target_at(body.y),
+ Some((0, 0, first_pane))
+ );
+ assert_eq!(app.state.agent_detail_target_at(body.y + 1), None);
+ assert_eq!(
+ app.state.agent_detail_target_at(body.y + 3),
+ Some((1, 0, second_pane))
+ );
+ }
+
+ #[test]
fn clicking_agent_panel_toggle_switches_sort() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("test")];
@@ -866,6 +914,14 @@ mod tests {
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
+ app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
+ app.state.sidebar_agents.rows_by_agent.insert(
+ "claude".into(),
+ vec![
+ vec![crate::config::AgentSidebarToken::Agent],
+ vec![crate::config::AgentSidebarToken::Workspace],
+ ],
+ );
app.state.agent_panel_scroll = 1;
let detail_area = app.state.agent_panel_rect();
@@ -873,7 +929,7 @@ mod tests {
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
body.x + 1,
- body.y,
+ body.y + 1,
));
assert_eq!(app.state.workspaces[0].active_tab, second_tab);
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 99fc12a6..bff04c44 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -18,6 +18,7 @@ mod runtime_mutations;
mod session;
pub mod state;
mod terminal_targets;
+mod terminal_titles;
mod theme_sync;
mod worktrees;
@@ -598,6 +599,8 @@ impl App {
sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode,
sidebar_section_split,
agent_panel_sort,
+ sidebar_agents: config.ui.sidebar.agents.clone(),
+ sidebar_spaces: config.ui.sidebar.spaces.clone(),
next_agent_state_change_seq: 0,
mouse_capture: config.ui.mouse_capture,
copy_on_select: config.ui.copy_on_select,
@@ -879,12 +882,19 @@ impl App {
if self.render_dirty.load(Ordering::Acquire) {
needs_render = true;
}
+ let terminal_title_changed = self.sync_terminal_titles();
+ if terminal_title_changed && self.terminal_title_sidebar_configured() {
+ needs_render = true;
+ }
// Drain a bounded internal-event batch for responsiveness. API handlers
// perform an exhaustive drain before reading pane/runtime state.
if self.drain_internal_events() {
needs_render = true;
}
+ if self.expire_due_metadata(Instant::now()) {
+ needs_render = true;
+ }
if self.drain_api_requests() {
needs_render = true;
}
@@ -1390,6 +1400,8 @@ impl App {
self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab;
self.state.agent_panel_sort =
agent_panel_sort_from_config(config.ui.agent_panel_sort);
+ self.state.sidebar_agents = config.ui.sidebar.agents.clone();
+ self.state.sidebar_spaces = config.ui.sidebar.spaces.clone();
self.state.agent_panel_scroll = 0;
self.state.accent = crate::config::parse_color(&config.ui.accent);
if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
@@ -2686,6 +2698,59 @@ mod tests {
}
#[test]
+ fn reload_config_updates_sidebar_token_rows() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-sidebar-tokens");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+ let mut app = test_app();
+
+ std::fs::write(
+ &path,
+ "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\n",
+ )
+ .unwrap();
+ app.state.agent_panel_scroll = 5;
+ let report = app.reload_config();
+
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.agent_panel_scroll, 0);
+ assert_eq!(
+ app.state.sidebar_agents.rows,
+ vec![vec![
+ crate::config::AgentSidebarToken::StateIcon,
+ crate::config::AgentSidebarToken::Custom("summary".into()),
+ ]]
+ );
+ assert_eq!(
+ app.state.sidebar_agents.rows_by_agent["claude"],
+ vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]]
+ );
+ assert_eq!(
+ app.state.sidebar_spaces.rows,
+ vec![vec![
+ crate::config::SpaceSidebarToken::Workspace,
+ crate::config::SpaceSidebarToken::Custom("jj_status".into()),
+ ]]
+ );
+
+ let previous_agents = app.state.sidebar_agents.clone();
+ std::fs::write(
+ &path,
+ "[ui.sidebar.agents]\nrows = [[\"agent\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude-code = [[\"terminal_title\"]]\n",
+ )
+ .unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
+ assert_eq!(app.state.sidebar_agents, previous_agents);
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn reload_config_updates_sidebar_collapsed_mode() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-sidebar-collapsed-mode");
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 83ca2ea6..88081743 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -62,7 +62,8 @@ impl App {
msg: crate::api::ApiRequestMessage,
) -> bool {
let previous_mode = self.state.mode;
- let mut changed = crate::api::request_changes_ui(&msg.request);
+ let mut changed = self.expire_due_metadata(Instant::now());
+ changed |= crate::api::request_changes_ui(&msg.request);
let skip_default_workspace = matches!(
&msg.request.method,
crate::api::schema::Method::ServerStop(_)
@@ -288,18 +289,7 @@ impl App {
self.start_background_session_save();
}
- if let Some(deadline) = self
- .agent_metadata_deadline
- .filter(|deadline| now >= *deadline)
- {
- let previous_toast = self.state.toast.clone();
- for update in self.state.expire_agent_metadata_at(deadline, now) {
- self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
- self.emit_pane_state_update(&update);
- }
- self.sync_agent_metadata_deadline();
- changed = true;
- }
+ changed |= self.expire_due_metadata(now);
if geometry_dirty || resized {
self.pending_agent_resume_deadline = None;
@@ -337,6 +327,33 @@ impl App {
self.agent_metadata_deadline = self.state.next_agent_metadata_expiry();
}
+ pub(crate) fn expire_due_metadata(&mut self, now: Instant) -> bool {
+ let Some(deadline) = self
+ .agent_metadata_deadline
+ .filter(|deadline| now >= *deadline)
+ else {
+ return false;
+ };
+ self.expire_metadata_at(deadline, now);
+ true
+ }
+
+ pub(crate) fn expire_metadata_at(&mut self, deadline: Instant, now: Instant) {
+ let previous_toast = self.state.toast.clone();
+ for update in self.state.expire_agent_metadata_at(deadline, now) {
+ self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
+ self.emit_pane_state_update(&update);
+ }
+ let (panes, workspaces) = self.state.expire_metadata_tokens(now);
+ for (ws_idx, pane_id) in panes {
+ self.emit_pane_updated(ws_idx, pane_id);
+ }
+ for ws_idx in workspaces {
+ self.emit_workspace_token_updated(ws_idx);
+ }
+ self.sync_agent_metadata_deadline();
+ }
+
pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
}
diff --git a/src/app/state.rs b/src/app/state.rs
index be858678..d2315422 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1403,6 +1403,8 @@ pub struct AppState {
/// Ratio of sidebar height allocated to the workspaces section.
pub sidebar_section_split: f32,
pub agent_panel_sort: AgentPanelSort,
+ pub sidebar_agents: crate::config::AgentsSidebarConfig,
+ pub sidebar_spaces: crate::config::SpacesSidebarConfig,
pub next_agent_state_change_seq: u64,
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
/// captures mouse while the focused pane app requests mouse reporting.
@@ -1764,6 +1766,8 @@ impl AppState {
sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact,
sidebar_section_split: 0.5,
agent_panel_sort: AgentPanelSort::Spaces,
+ sidebar_agents: crate::config::AgentsSidebarConfig::default(),
+ sidebar_spaces: crate::config::SpacesSidebarConfig::default(),
next_agent_state_change_seq: 0,
mouse_capture: true,
copy_on_select: true,
diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs
new file mode 100644
index 00000000..3c7d9711
--- /dev/null
+++ b/src/app/terminal_titles.rs
@@ -0,0 +1,148 @@
+use super::App;
+
+impl App {
+ pub(crate) fn terminal_title_sidebar_configured(&self) -> bool {
+ let config = &self.state.sidebar_agents;
+ std::iter::once(&config.rows)
+ .chain(config.rows_by_agent.values())
+ .flatten()
+ .flatten()
+ .any(|token| {
+ matches!(
+ token,
+ crate::config::AgentSidebarToken::TerminalTitle
+ | crate::config::AgentSidebarToken::TerminalTitleStripped
+ )
+ })
+ }
+
+ pub(crate) fn sync_terminal_titles(&mut self) -> bool {
+ let mut observations = Vec::new();
+ for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
+ for tab in &workspace.tabs {
+ for (pane_id, pane) in &tab.panes {
+ let terminal_id = &pane.attached_terminal_id;
+ let Some(runtime) = self.terminal_runtimes.get(terminal_id) else {
+ continue;
+ };
+ observations.push((
+ ws_idx,
+ *pane_id,
+ terminal_id.clone(),
+ runtime.terminal_title(),
+ ));
+ }
+ }
+ }
+
+ let mut raw_changed = false;
+ let mut publish = Vec::new();
+ for (ws_idx, pane_id, terminal_id, title) in observations {
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ continue;
+ };
+ let change = terminal.set_terminal_title(title);
+ raw_changed |= change.raw_changed;
+ if change.stripped_changed {
+ publish.push((ws_idx, pane_id));
+ }
+ }
+
+ for (ws_idx, pane_id) in publish {
+ self.emit_pane_updated(ws_idx, pane_id);
+ }
+
+ raw_changed
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+ use crate::detect::{Agent, AgentState};
+ use crate::workspace::Workspace;
+
+ #[tokio::test]
+ async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(Agent::Claude);
+ terminal.state = AgentState::Working;
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes());
+ app.terminal_runtimes.insert(terminal_id.clone(), runtime);
+
+ assert!(app.sync_terminal_titles());
+ let pane = app.pane_info(0, pane_id).unwrap();
+ assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题"));
+ assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
+ assert_eq!(pane.title, None);
+ assert_eq!(pane.agent_status, crate::api::schema::AgentStatus::Working);
+ assert_eq!(pane.revision, 1);
+ let agent = app.collect_agent_infos().pop().unwrap();
+ assert_eq!(agent.terminal_title.as_deref(), Some("⠋ 修复🙂标题"));
+ assert_eq!(agent.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
+
+ app.terminal_runtimes
+ .get(&terminal_id)
+ .unwrap()
+ .test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes());
+ assert!(app.sync_terminal_titles());
+ let pane = app.pane_info(0, pane_id).unwrap();
+ assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题"));
+ assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
+ assert_eq!(pane.revision, 1);
+ assert_eq!(pane_updated_events(&event_hub), 1);
+
+ app.terminal_runtimes
+ .get(&terminal_id)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b]0;Done reviewing\x07");
+ assert!(app.sync_terminal_titles());
+ assert_eq!(pane_updated_events(&event_hub), 2);
+
+ app.terminal_runtimes
+ .get(&terminal_id)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b]0;\x07");
+ assert!(app.sync_terminal_titles());
+ let pane = app.pane_info(0, pane_id).unwrap();
+ assert_eq!(pane.terminal_title, None);
+ assert_eq!(pane.terminal_title_stripped, None);
+ assert_eq!(pane.revision, 3);
+ assert_eq!(pane_updated_events(&event_hub), 3);
+ }
+
+ #[test]
+ fn override_only_terminal_title_token_requests_sidebar_redraws() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
+ app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
+ app.state.sidebar_agents.rows_by_agent.insert(
+ "claude".into(),
+ vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]],
+ );
+
+ assert!(app.terminal_title_sidebar_configured());
+ }
+
+ fn pane_updated_events(event_hub: &crate::api::EventHub) -> usize {
+ event_hub
+ .events_after(0)
+ .iter()
+ .filter(|(_, event)| event.event == crate::api::schema::EventKind::PaneUpdated)
+ .count()
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 494ed14e..1ce262b4 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -30,6 +30,16 @@ const TERMINAL_SESSION_OBSERVE_USAGE: &str =
const TERMINAL_SESSION_CONTROL_USAGE: &str =
"usage: herdr terminal session control <target> [--takeover] [--cols N] [--rows N]";
+pub(crate) fn parse_token_assignment(raw: &str) -> Result<(String, Option<String>), String> {
+ let Some((key, value)) = raw.split_once('=') else {
+ return Err("token must use NAME=VALUE".into());
+ };
+ if key.is_empty() {
+ return Err("token name must not be empty".into());
+ }
+ Ok((key.to_string(), Some(value.to_string())))
+}
+
pub(crate) fn parse_env_assignment(raw: &str) -> Result<(String, String), String> {
let Some((key, value)) = raw.split_once('=') else {
return Err("env must use KEY=VALUE".into());
@@ -881,7 +891,6 @@ fn wait_for_agent_status_change(
agent,
title,
display_agent,
- custom_status,
state_labels,
} = event.data
else {
@@ -895,7 +904,6 @@ fn wait_for_agent_status_change(
workspace_id,
agent_status,
agent,
- custom_status,
title,
display_agent,
state_labels,
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index a178eb4b..80e10c69 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -943,7 +943,7 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
- eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
+ eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
return Ok(2);
};
@@ -952,7 +952,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
let mut agent = None;
let mut state = None;
let mut message = None;
- let mut custom_status = None;
let mut seq = None;
let mut agent_session_id = None;
let mut agent_session_path = None;
@@ -992,14 +991,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
message = Some(value.clone());
index += 2;
}
- "--custom-status" => {
- let Some(value) = args.get(index + 1) else {
- eprintln!("missing value for --custom-status");
- return Ok(2);
- };
- custom_status = Some(value.clone());
- index += 2;
- }
"--seq" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --seq");
@@ -1053,7 +1044,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
agent,
state,
message,
- custom_status,
seq,
agent_session_id,
agent_session_path,
@@ -1224,7 +1214,7 @@ fn pane_release_agent(args: &[String]) -> std::io::Result<i32> {
fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
- eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]");
+ eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
return Ok(2);
};
@@ -1234,11 +1224,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
let mut applies_to_source = None;
let mut title = None;
let mut display_agent = None;
- let mut custom_status = None;
let mut state_labels = std::collections::HashMap::new();
+ let mut tokens = std::collections::HashMap::new();
let mut clear_title = false;
let mut clear_display_agent = false;
- let mut clear_custom_status = false;
let mut clear_state_labels = false;
let mut seq = None;
let mut ttl_ms = None;
@@ -1294,18 +1283,6 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
clear_display_agent = true;
index += 1;
}
- "--custom-status" => {
- let Some(value) = args.get(index + 1) else {
- eprintln!("missing value for --custom-status");
- return Ok(2);
- };
- custom_status = Some(value.clone());
- index += 2;
- }
- "--clear-custom-status" => {
- clear_custom_status = true;
- index += 1;
- }
"--state-label" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --state-label");
@@ -1330,6 +1307,29 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
clear_state_labels = true;
index += 1;
}
+ "--token" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --token");
+ return Ok(2);
+ };
+ let (key, value) = match super::parse_token_assignment(value) {
+ Ok(token) => token,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+ tokens.insert(key, value);
+ index += 2;
+ }
+ "--clear-token" => {
+ let Some(key) = args.get(index + 1) else {
+ eprintln!("missing value for --clear-token");
+ return Ok(2);
+ };
+ tokens.insert(key.clone(), None);
+ index += 2;
+ }
"--seq" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --seq");
@@ -1369,7 +1369,6 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
}
if title.is_some() && clear_title
|| display_agent.is_some() && clear_display_agent
- || custom_status.is_some() && clear_custom_status
|| !state_labels.is_empty() && clear_state_labels
{
eprintln!("cannot set and clear the same metadata field");
@@ -1377,11 +1376,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
}
if title.is_none()
&& display_agent.is_none()
- && custom_status.is_none()
&& state_labels.is_empty()
+ && tokens.is_empty()
&& !clear_title
&& !clear_display_agent
- && !clear_custom_status
&& !clear_state_labels
{
eprintln!("missing metadata field to set or clear");
@@ -1395,11 +1393,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> {
applies_to_source,
title,
display_agent,
- custom_status,
state_labels,
+ tokens,
clear_title,
clear_display_agent,
- clear_custom_status,
clear_state_labels,
seq,
ttl_ms,
@@ -1433,10 +1430,10 @@ fn print_pane_help() {
eprintln!(" herdr pane close <pane_id>");
eprintln!(" herdr pane send-text <pane_id> <text>");
eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
- eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
+ eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]");
- eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]");
+ eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
eprintln!(" herdr pane run <pane_id> <command>");
}
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
index 4c68e3a5..f27eed49 100644
--- a/src/cli/spec.rs
+++ b/src/cli/spec.rs
@@ -160,6 +160,16 @@ fn workspace_command() -> Command {
.arg(required("workspace_id", "WORKSPACE_ID"))
.arg(required("label", "LABEL").num_args(1..)),
)
+ .subcommand(
+ Command::new("report-metadata")
+ .about("Report display-only workspace metadata")
+ .arg(required("workspace_id", "WORKSPACE_ID"))
+ .arg(option("source", "ID"))
+ .arg(repeatable_option("token", "NAME=VALUE"))
+ .arg(repeatable_option("clear-token", "NAME"))
+ .arg(option("seq", "N"))
+ .arg(option("ttl-ms", "N")),
+ )
.subcommand(id_command("close", "workspace_id", "Close a workspace"))
}
@@ -467,7 +477,6 @@ fn report_agent_command() -> Command {
.arg(option("agent", "LABEL"))
.arg(pane_agent_state_option("state"))
.arg(option("message", "TEXT"))
- .arg(option("custom-status", "TEXT"))
.arg(option("seq", "N"))
.arg(option("agent-session-id", "ID"))
.arg(path_option("agent-session-path", "PATH"))
@@ -505,10 +514,10 @@ fn report_metadata_command() -> Command {
.arg(flag("clear-title"))
.arg(option("display-agent", "TEXT"))
.arg(flag("clear-display-agent"))
- .arg(option("custom-status", "TEXT"))
- .arg(flag("clear-custom-status"))
.arg(option("state-label", "STATUS=TEXT"))
.arg(flag("clear-state-labels"))
+ .arg(repeatable_option("token", "NAME=VALUE"))
+ .arg(repeatable_option("clear-token", "NAME"))
.arg(option("seq", "N"))
.arg(option("ttl-ms", "N"))
}
@@ -817,6 +826,10 @@ fn option(name: &'static str, value_name: &'static str) -> Arg {
.action(ArgAction::Set)
}
+fn repeatable_option(name: &'static str, value_name: &'static str) -> Arg {
+ option(name, value_name).action(ArgAction::Append)
+}
+
fn path_option(name: &'static str, value_name: &'static str) -> Arg {
option(name, value_name).value_hint(ValueHint::AnyPath)
}
diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs
index e59a3663..525fc971 100644
--- a/src/cli/workspace.rs
+++ b/src/cli/workspace.rs
@@ -1,6 +1,8 @@
use std::collections::HashMap;
-use crate::api::schema::{WorkspaceCreateParams, WorkspaceRenameParams};
+use crate::api::schema::{
+ Method, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceReportMetadataParams,
+};
pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
@@ -14,6 +16,7 @@ pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> {
"get" => workspace_get(&args[1..]),
"focus" => workspace_focus(&args[1..]),
"rename" => workspace_rename(&args[1..]),
+ "report-metadata" => workspace_report_metadata(&args[1..]),
"close" => workspace_close(&args[1..]),
"help" | "--help" | "-h" => {
print_workspace_help();
@@ -136,6 +139,91 @@ fn workspace_rename(args: &[String]) -> std::io::Result<i32> {
})
}
+fn workspace_report_metadata(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_workspace_id) = args.first() else {
+ eprintln!("usage: herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
+ return Ok(2);
+ };
+ let workspace_id = super::normalize_workspace_id(raw_workspace_id);
+ let mut source = None;
+ let mut tokens = HashMap::new();
+ let mut seq = None;
+ let mut ttl_ms = None;
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--source" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --source");
+ return Ok(2);
+ };
+ source = Some(value.clone());
+ index += 2;
+ }
+ "--token" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --token");
+ return Ok(2);
+ };
+ let (key, value) = match super::parse_token_assignment(value) {
+ Ok(token) => token,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+ tokens.insert(key, value);
+ index += 2;
+ }
+ "--clear-token" => {
+ let Some(key) = args.get(index + 1) else {
+ eprintln!("missing value for --clear-token");
+ return Ok(2);
+ };
+ tokens.insert(key.clone(), None);
+ index += 2;
+ }
+ "--seq" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --seq");
+ return Ok(2);
+ };
+ seq = Some(super::parse_u64_flag("--seq", value)?);
+ index += 2;
+ }
+ "--ttl-ms" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --ttl-ms");
+ return Ok(2);
+ };
+ ttl_ms = Some(super::parse_u64_flag("--ttl-ms", value)?);
+ index += 2;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+ let Some(source) = source.filter(|source| !source.trim().is_empty()) else {
+ eprintln!("missing required --source");
+ return Ok(2);
+ };
+ if tokens.is_empty() {
+ eprintln!("missing token to set or clear");
+ return Ok(2);
+ }
+ super::send_ok_request(Method::WorkspaceReportMetadata(
+ WorkspaceReportMetadataParams {
+ workspace_id,
+ source,
+ tokens,
+ seq,
+ ttl_ms,
+ },
+ ))
+}
+
fn workspace_close(args: &[String]) -> std::io::Result<i32> {
let Some(raw_workspace_id) = args.first() else {
eprintln!("usage: herdr workspace close <workspace_id>");
@@ -156,5 +244,6 @@ fn print_workspace_help() {
eprintln!(" herdr workspace get <workspace_id>");
eprintln!(" herdr workspace focus <workspace_id>");
eprintln!(" herdr workspace rename <workspace_id> <label>");
+ eprintln!(" herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
eprintln!(" herdr workspace close <workspace_id>");
}
diff --git a/src/config.rs b/src/config.rs
index a2d3f89f..b9a476e8 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -3,6 +3,7 @@ use crossterm::event::{KeyCode, KeyModifiers};
mod io;
mod keybinds;
mod model;
+mod sidebar;
mod sound;
mod theme;
@@ -23,6 +24,10 @@ pub use self::{
SidebarCollapsedModeConfig, ToastClipboardPosition, ToastConfig, ToastDelivery,
ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
},
+ sidebar::{
+ AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SpaceSidebarToken,
+ SpacesSidebarConfig,
+ },
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
};
diff --git a/src/config/model.rs b/src/config/model.rs
index 16bc595d..60dd2471 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -4,8 +4,8 @@ use crossterm::event::KeyModifiers;
use serde::{de, Deserialize, Deserializer, Serialize};
use super::{
- ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SoundConfig,
- ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES,
+ ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SidebarConfig,
+ SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES,
DEFAULT_SCROLLBACK_LIMIT_BYTES,
};
@@ -810,6 +810,8 @@ pub struct UiConfig {
pub hide_tab_bar_when_single_tab: bool,
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
pub agent_panel_sort: AgentPanelSortConfig,
+ /// Expanded sidebar row composition.
+ pub sidebar: SidebarConfig,
/// Accent color for highlights, borders, and navigation UI.
/// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)).
pub accent: String,
@@ -1003,6 +1005,7 @@ impl Default for UiConfig {
show_agent_labels_on_pane_borders: false,
hide_tab_bar_when_single_tab: false,
agent_panel_sort: AgentPanelSortConfig::Spaces,
+ sidebar: SidebarConfig::default(),
accent: "cyan".into(),
toast: ToastConfig::default(),
sound: SoundConfig::default(),
diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs
new file mode 100644
index 00000000..d0ae5240
--- /dev/null
+++ b/src/config/sidebar.rs
@@ -0,0 +1,400 @@
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::detect::Agent;
+
+const MAX_SIDEBAR_ROWS: usize = 16;
+const MAX_SIDEBAR_TOKENS_PER_ROW: usize = 16;
+
+fn deserialize_sidebar_rows<'de, D, T>(deserializer: D) -> Result<Vec<Vec<T>>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+ T: Deserialize<'de>,
+{
+ let rows = Vec::<Vec<T>>::deserialize(deserializer)?;
+ validate_sidebar_rows(&rows).map_err(serde::de::Error::custom)?;
+ Ok(rows)
+}
+
+fn validate_sidebar_rows<T>(rows: &[Vec<T>]) -> Result<(), String> {
+ if rows.len() > MAX_SIDEBAR_ROWS {
+ return Err(format!(
+ "sidebar layouts may contain at most {MAX_SIDEBAR_ROWS} rows"
+ ));
+ }
+ if rows
+ .iter()
+ .any(|row| row.len() > MAX_SIDEBAR_TOKENS_PER_ROW)
+ {
+ return Err(format!(
+ "sidebar rows may contain at most {MAX_SIDEBAR_TOKENS_PER_ROW} tokens"
+ ));
+ }
+ Ok(())
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AgentSidebarToken {
+ StateIcon,
+ StateText,
+ Workspace,
+ Tab,
+ Pane,
+ Agent,
+ TerminalTitle,
+ TerminalTitleStripped,
+ Custom(String),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SpaceSidebarToken {
+ StateIcon,
+ StateText,
+ Workspace,
+ Branch,
+ GitStatus,
+ Custom(String),
+}
+
+fn parse_sidebar_token<'de, D, T>(deserializer: D, builtins: &[(&str, T)]) -> Result<T, D::Error>
+where
+ D: serde::Deserializer<'de>,
+ T: Clone + From<String>,
+{
+ let value = String::deserialize(deserializer)?;
+ if let Some((_, token)) = builtins.iter().find(|(name, _)| *name == value) {
+ return Ok(token.clone());
+ }
+ let Some(name) = value.strip_prefix('$') else {
+ return Err(serde::de::Error::custom(format!(
+ "unknown sidebar token `{value}`; custom tokens must start with `$`"
+ )));
+ };
+ if name.is_empty()
+ || name.len() > 32
+ || !name
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
+ {
+ return Err(serde::de::Error::custom(format!(
+ "invalid custom sidebar token `{value}`"
+ )));
+ }
+ Ok(T::from(name.to_string()))
+}
+
+impl Serialize for AgentSidebarToken {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ match self {
+ Self::StateIcon => serializer.serialize_str("state_icon"),
+ Self::StateText => serializer.serialize_str("state_text"),
+ Self::Workspace => serializer.serialize_str("workspace"),
+ Self::Tab => serializer.serialize_str("tab"),
+ Self::Pane => serializer.serialize_str("pane"),
+ Self::Agent => serializer.serialize_str("agent"),
+ Self::TerminalTitle => serializer.serialize_str("terminal_title"),
+ Self::TerminalTitleStripped => serializer.serialize_str("terminal_title_stripped"),
+ Self::Custom(name) => serializer.serialize_str(&format!("${name}")),
+ }
+ }
+}
+
+impl From<String> for AgentSidebarToken {
+ fn from(value: String) -> Self {
+ Self::Custom(value)
+ }
+}
+
+impl<'de> Deserialize<'de> for AgentSidebarToken {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ parse_sidebar_token(
+ deserializer,
+ &[
+ ("state_icon", Self::StateIcon),
+ ("state_text", Self::StateText),
+ ("workspace", Self::Workspace),
+ ("tab", Self::Tab),
+ ("pane", Self::Pane),
+ ("agent", Self::Agent),
+ ("terminal_title", Self::TerminalTitle),
+ ("terminal_title_stripped", Self::TerminalTitleStripped),
+ ],
+ )
+ }
+}
+
+impl Serialize for SpaceSidebarToken {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ match self {
+ Self::StateIcon => serializer.serialize_str("state_icon"),
+ Self::StateText => serializer.serialize_str("state_text"),
+ Self::Workspace => serializer.serialize_str("workspace"),
+ Self::Branch => serializer.serialize_str("branch"),
+ Self::GitStatus => serializer.serialize_str("git_status"),
+ Self::Custom(name) => serializer.serialize_str(&format!("${name}")),
+ }
+ }
+}
+
+impl From<String> for SpaceSidebarToken {
+ fn from(value: String) -> Self {
+ Self::Custom(value)
+ }
+}
+
+impl<'de> Deserialize<'de> for SpaceSidebarToken {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ parse_sidebar_token(
+ deserializer,
+ &[
+ ("state_icon", Self::StateIcon),
+ ("state_text", Self::StateText),
+ ("workspace", Self::Workspace),
+ ("branch", Self::Branch),
+ ("git_status", Self::GitStatus),
+ ],
+ )
+ }
+}
+
+type AgentSidebarRows = Vec<Vec<AgentSidebarToken>>;
+type SpaceSidebarRows = Vec<Vec<SpaceSidebarToken>>;
+
+fn deserialize_rows_by_agent<'de, D>(
+ deserializer: D,
+) -> Result<BTreeMap<String, AgentSidebarRows>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let rows_by_agent = BTreeMap::<String, AgentSidebarRows>::deserialize(deserializer)?;
+ for (id, rows) in &rows_by_agent {
+ if crate::detect::parse_canonical_agent_label(id).is_none() {
+ return Err(serde::de::Error::custom(format!(
+ "unknown canonical agent id `{id}` in sidebar rows_by_agent"
+ )));
+ }
+ validate_sidebar_rows(rows).map_err(serde::de::Error::custom)?;
+ }
+ Ok(rows_by_agent)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(default)]
+pub struct AgentsSidebarConfig {
+ #[serde(deserialize_with = "deserialize_sidebar_rows")]
+ pub rows: AgentSidebarRows,
+ #[serde(default, deserialize_with = "deserialize_rows_by_agent")]
+ pub rows_by_agent: BTreeMap<String, AgentSidebarRows>,
+}
+
+impl AgentsSidebarConfig {
+ pub(crate) fn rows_for_agent(&self, agent: Option<Agent>) -> &AgentSidebarRows {
+ agent
+ .and_then(|agent| self.rows_by_agent.get(crate::detect::agent_label(agent)))
+ .unwrap_or(&self.rows)
+ }
+}
+
+impl Default for AgentsSidebarConfig {
+ fn default() -> Self {
+ Self {
+ rows: vec![
+ vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Workspace,
+ AgentSidebarToken::Tab,
+ ],
+ vec![AgentSidebarToken::Agent],
+ ],
+ rows_by_agent: BTreeMap::new(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(default)]
+pub struct SpacesSidebarConfig {
+ #[serde(deserialize_with = "deserialize_sidebar_rows")]
+ pub rows: SpaceSidebarRows,
+}
+
+impl Default for SpacesSidebarConfig {
+ fn default() -> Self {
+ Self {
+ rows: vec![
+ vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace],
+ vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus],
+ ],
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
+#[serde(default)]
+pub struct SidebarConfig {
+ pub agents: AgentsSidebarConfig,
+ pub spaces: SpacesSidebarConfig,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn defaults_match_the_compact_agent_and_existing_space_layouts() {
+ let config = SidebarConfig::default();
+ assert_eq!(
+ config.agents.rows,
+ vec![
+ vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Workspace,
+ AgentSidebarToken::Tab,
+ ],
+ vec![AgentSidebarToken::Agent],
+ ]
+ );
+ assert!(config.agents.rows_by_agent.is_empty());
+ assert_eq!(
+ config.spaces.rows,
+ vec![
+ vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace],
+ vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus],
+ ]
+ );
+ }
+
+ #[test]
+ fn parses_builtin_and_arbitrary_custom_tokens() {
+ let config: crate::config::Config = toml::from_str(
+ r#"
+[ui.sidebar.agents]
+rows = [["state_icon", "workspace"], ["state_text", "agent", "$summary"], ["terminal_title", "terminal_title_stripped", "$terminal_title"]]
+
+[ui.sidebar.agents.rows_by_agent]
+claude = [["terminal_title_stripped"], ["agent", "$model"]]
+
+[ui.sidebar.spaces]
+rows = [["workspace"], ["$jj_status"]]
+"#,
+ )
+ .expect("sidebar token config");
+
+ assert_eq!(
+ config.ui.sidebar.agents.rows[1],
+ vec![
+ AgentSidebarToken::StateText,
+ AgentSidebarToken::Agent,
+ AgentSidebarToken::Custom("summary".into()),
+ ]
+ );
+ assert_eq!(
+ config.ui.sidebar.agents.rows[2],
+ vec![
+ AgentSidebarToken::TerminalTitle,
+ AgentSidebarToken::TerminalTitleStripped,
+ AgentSidebarToken::Custom("terminal_title".into()),
+ ]
+ );
+ assert_eq!(
+ config.ui.sidebar.agents.rows_by_agent["claude"],
+ vec![
+ vec![AgentSidebarToken::TerminalTitleStripped],
+ vec![
+ AgentSidebarToken::Agent,
+ AgentSidebarToken::Custom("model".into()),
+ ],
+ ]
+ );
+ assert_eq!(
+ config.ui.sidebar.spaces.rows[1],
+ vec![SpaceSidebarToken::Custom("jj_status".into())]
+ );
+ }
+
+ #[test]
+ fn rejects_unknown_bare_and_malformed_custom_tokens() {
+ for token in ["summary", "$", "$bad.name"] {
+ let input = format!("[ui.sidebar.agents]\\nrows = [[\"{token}\"]]\\n");
+ assert!(toml::from_str::<crate::config::Config>(&input).is_err());
+ }
+ }
+
+ #[test]
+ fn rejects_oversized_sidebar_layouts() {
+ let too_many_rows = std::iter::repeat_n("[\"agent\"]", MAX_SIDEBAR_ROWS + 1)
+ .collect::<Vec<_>>()
+ .join(",");
+ let input = format!("[ui.sidebar.agents]\nrows = [{too_many_rows}]\n");
+ assert!(toml::from_str::<crate::config::Config>(&input).is_err());
+
+ let too_many_tokens = std::iter::repeat_n("\"workspace\"", MAX_SIDEBAR_TOKENS_PER_ROW + 1)
+ .collect::<Vec<_>>()
+ .join(",");
+ let input = format!("[ui.sidebar.spaces]\nrows = [[{too_many_tokens}]]\n");
+ assert!(toml::from_str::<crate::config::Config>(&input).is_err());
+
+ let input = format!("[ui.sidebar.agents.rows_by_agent]\nclaude = [{too_many_rows}]\n");
+ assert!(toml::from_str::<crate::config::Config>(&input).is_err());
+ }
+
+ #[test]
+ fn accepts_every_canonical_agent_override_key() {
+ let agents = [
+ Agent::Pi,
+ Agent::Claude,
+ Agent::Codex,
+ Agent::Gemini,
+ Agent::Cursor,
+ Agent::Devin,
+ Agent::Antigravity,
+ Agent::Cline,
+ Agent::Omp,
+ Agent::Mastracode,
+ Agent::OpenCode,
+ Agent::GithubCopilot,
+ Agent::Kimi,
+ Agent::Kiro,
+ Agent::Droid,
+ Agent::Amp,
+ Agent::Grok,
+ Agent::Hermes,
+ Agent::Kilo,
+ Agent::Qodercli,
+ Agent::Maki,
+ ];
+ let entries = agents
+ .iter()
+ .map(|agent| format!("{} = [[\"agent\"]]", crate::detect::agent_label(*agent)))
+ .collect::<Vec<_>>()
+ .join("\n");
+ let input = format!("[ui.sidebar.agents.rows_by_agent]\n{entries}\n");
+ let config: crate::config::Config = toml::from_str(&input).expect("canonical keys");
+
+ assert_eq!(config.ui.sidebar.agents.rows_by_agent.len(), agents.len());
+ }
+
+ #[test]
+ fn rejects_alias_case_whitespace_and_unknown_override_keys() {
+ for key in ["claude-code", "Claude", "' claude '", "unknown"] {
+ let input = format!("[ui.sidebar.agents.rows_by_agent]\n{key} = [[\"agent\"]]\n");
+ assert!(
+ toml::from_str::<crate::config::Config>(&input).is_err(),
+ "accepted key {key:?}"
+ );
+ }
+ }
+}
diff --git a/src/events.rs b/src/events.rs
index 64aa86f0..a3869ef7 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -73,7 +73,6 @@ pub enum AppEvent {
agent_label: String,
state: AgentState,
message: Option<String>,
- custom_status: Option<String>,
seq: Option<u64>,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
},
@@ -94,11 +93,9 @@ pub enum AppEvent {
applies_to_source: Option<String>,
title: Option<String>,
display_agent: Option<String>,
- custom_status: Option<String>,
state_labels: std::collections::HashMap<String, String>,
clear_title: bool,
clear_display_agent: bool,
- clear_custom_status: bool,
clear_state_labels: bool,
seq: Option<u64>,
ttl: Option<std::time::Duration>,
diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs
index 6d7b82fe..e5121a88 100644
--- a/src/handoff_runtime.rs
+++ b/src/handoff_runtime.rs
@@ -17,6 +17,8 @@ pub(crate) struct HandoffRuntimeState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_state: Option<crate::pane::InputState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub terminal_title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_history_ansi: Option<String>,
}
diff --git a/src/main.rs b/src/main.rs
index 57ca4dc2..06424fea 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -73,6 +73,7 @@ mod ipc;
mod kitty_graphics;
mod layout;
mod logging;
+mod metadata_tokens;
mod pane;
mod persist;
mod platform;
@@ -304,6 +305,20 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# "workspaces" is accepted as an alias for "spaces".
# agent_panel_sort = "spaces"
+# Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent,
+# terminal_title, and terminal_title_stripped.
+# Custom values reported through pane metadata use a $name token.
+# [ui.sidebar.agents]
+# rows = [["state_icon", "workspace", "tab"], ["agent"]]
+# Optional canonical agent IDs replace the default rows for matching agents.
+# [ui.sidebar.agents.rows_by_agent]
+# claude = [["state_icon", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]]
+
+# Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status.
+# Custom values reported through workspace metadata use a $name token, for example $jj_status.
+# [ui.sidebar.spaces]
+# rows = [["state_icon", "workspace"], ["branch", "git_status"]]
+
# Accent color for highlights, borders, and navigation UI.
# Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b)
# accent = "cyan"
diff --git a/src/metadata_tokens.rs b/src/metadata_tokens.rs
new file mode 100644
index 00000000..f79e8ae2
--- /dev/null
+++ b/src/metadata_tokens.rs
@@ -0,0 +1,259 @@
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct MetadataToken {
+ value: String,
+ expires_at: Option<Instant>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub(crate) struct MetadataTokens {
+ entries: HashMap<String, MetadataToken>,
+}
+
+pub(crate) const MAX_SEQUENCE_SOURCES: usize = 32;
+
+pub(crate) fn sequence_is_fresh(
+ sequences: &HashMap<String, u64>,
+ source: &str,
+ seq: Option<u64>,
+) -> bool {
+ seq.is_none_or(|seq| sequences.get(source).is_none_or(|last| seq > *last))
+}
+
+pub(crate) fn accept_sequence(
+ sequences: &mut HashMap<String, u64>,
+ source: &str,
+ seq: Option<u64>,
+) -> Result<bool, ()> {
+ let Some(seq) = seq else {
+ return Ok(true);
+ };
+ if !sequence_is_fresh(sequences, source, Some(seq)) {
+ return Ok(false);
+ }
+ if !sequences.contains_key(source) && sequences.len() >= MAX_SEQUENCE_SOURCES {
+ return Err(());
+ }
+ sequences.insert(source.to_string(), seq);
+ Ok(true)
+}
+
+impl MetadataTokens {
+ pub(crate) fn patch(
+ &mut self,
+ patch: HashMap<String, Option<String>>,
+ ttl: Option<Duration>,
+ now: Instant,
+ ) -> bool {
+ let expires_at = ttl.and_then(|ttl| now.checked_add(ttl));
+ let mut changed = false;
+ for (key, value) in patch {
+ match value {
+ Some(value) => {
+ let token = MetadataToken { value, expires_at };
+ if self.entries.get(&key) != Some(&token) {
+ self.entries.insert(key, token);
+ changed = true;
+ }
+ }
+ None => {
+ changed |= self.entries.remove(&key).is_some();
+ }
+ }
+ }
+ changed
+ }
+
+ pub(crate) fn key_count_after_patch(&self, patch: &HashMap<String, Option<String>>) -> usize {
+ let mut keys = self
+ .values()
+ .into_keys()
+ .collect::<std::collections::HashSet<_>>();
+ for (key, value) in patch {
+ if value.is_some() {
+ keys.insert(key.clone());
+ } else {
+ keys.remove(key);
+ }
+ }
+ keys.len()
+ }
+
+ pub(crate) fn values(&self) -> HashMap<String, String> {
+ self.entries
+ .iter()
+ .map(|(key, token)| (key.clone(), token.value.clone()))
+ .collect()
+ }
+
+ pub(crate) fn next_expiry(&self) -> Option<Instant> {
+ self.entries
+ .values()
+ .filter_map(|token| token.expires_at)
+ .min()
+ }
+
+ pub(crate) fn expire_at(&mut self, now: Instant) -> bool {
+ let before = self.entries.len();
+ self.entries
+ .retain(|_, token| token.expires_at.is_none_or(|deadline| deadline > now));
+ self.entries.len() != before
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn patch(items: &[(&str, Option<&str>)]) -> HashMap<String, Option<String>> {
+ items
+ .iter()
+ .map(|(key, value)| ((*key).into(), value.map(str::to_string)))
+ .collect()
+ }
+
+ #[test]
+ fn sequence_sources_are_bounded() {
+ let mut sequences = HashMap::new();
+ for index in 0..MAX_SEQUENCE_SOURCES {
+ assert_eq!(
+ accept_sequence(&mut sequences, &format!("source-{index}"), Some(1)),
+ Ok(true)
+ );
+ }
+ assert_eq!(
+ accept_sequence(&mut sequences, "one-too-many", Some(1)),
+ Err(())
+ );
+ assert_eq!(
+ accept_sequence(&mut sequences, "source-0", Some(1)),
+ Ok(false)
+ );
+ assert_eq!(
+ accept_sequence(&mut sequences, "source-0", Some(2)),
+ Ok(true)
+ );
+ }
+
+ #[test]
+ fn patches_and_clears_individual_keys() {
+ let now = Instant::now();
+ let mut tokens = MetadataTokens::default();
+ tokens.patch(
+ patch(&[("summary", Some("one")), ("model", Some("opus"))]),
+ None,
+ now,
+ );
+ tokens.patch(
+ patch(&[("summary", Some("two")), ("model", None)]),
+ None,
+ now,
+ );
+
+ assert_eq!(
+ tokens.values(),
+ HashMap::from([("summary".into(), "two".into())])
+ );
+ }
+
+ #[test]
+ fn ttl_only_changes_keys_in_the_patch() {
+ let now = Instant::now();
+ let deadline = now + Duration::from_secs(1);
+ let mut tokens = MetadataTokens::default();
+ tokens.patch(
+ patch(&[("short", Some("one"))]),
+ Some(Duration::from_secs(1)),
+ now,
+ );
+ tokens.patch(patch(&[("persistent", Some("two"))]), None, now);
+
+ assert!(tokens.expire_at(deadline));
+ assert_eq!(
+ tokens.values(),
+ HashMap::from([("persistent".into(), "two".into())])
+ );
+ }
+
+ #[test]
+ fn values_remain_stable_until_expiry_mutates_state() {
+ let now = Instant::now();
+ let deadline = now + Duration::from_secs(1);
+ let mut tokens = MetadataTokens::default();
+ tokens.patch(
+ patch(&[("summary", Some("temporary"))]),
+ Some(Duration::from_secs(1)),
+ now,
+ );
+
+ assert_eq!(
+ tokens.values(),
+ HashMap::from([("summary".into(), "temporary".into())])
+ );
+ assert!(tokens.expire_at(deadline));
+ assert!(tokens.values().is_empty());
+ }
+
+ #[test]
+ fn delayed_expiry_sweep_removes_every_token_due_at_now() {
+ let now = Instant::now();
+ let mut tokens = MetadataTokens::default();
+ tokens.patch(
+ patch(&[("first", Some("one"))]),
+ Some(Duration::from_secs(1)),
+ now,
+ );
+ tokens.patch(
+ patch(&[("second", Some("two"))]),
+ Some(Duration::from_secs(2)),
+ now,
+ );
+
+ assert!(tokens.expire_at(now + Duration::from_secs(10)));
+ assert!(tokens.values().is_empty());
+ }
+
+ #[test]
+ fn stale_expiry_does_not_clear_replacement() {
+ let now = Instant::now();
+ let first_deadline = now + Duration::from_secs(1);
+ let mut tokens = MetadataTokens::default();
+ tokens.patch(
+ patch(&[("summary", Some("old"))]),
+ Some(Duration::from_secs(1)),
+ now,
+ );
+ tokens.patch(
+ patch(&[("summary", Some("new"))]),
+ Some(Duration::from_secs(5)),
+ now,
+ );
+
+ assert!(!tokens.expire_at(first_deadline));
+ assert_eq!(
+ tokens.values(),
+ HashMap::from([("summary".into(), "new".into())])
+ );
+ }
+
+ #[test]
+ fn update_without_ttl_cancels_previous_expiry() {
+ let now = Instant::now();
+ let deadline = now + Duration::from_secs(1);
+ let mut tokens = MetadataTokens::default();
+ tokens.patch(
+ patch(&[("summary", Some("temporary"))]),
+ Some(Duration::from_secs(1)),
+ now,
+ );
+ tokens.patch(patch(&[("summary", Some("persistent"))]), None, now);
+
+ assert!(!tokens.expire_at(deadline));
+ assert_eq!(
+ tokens.values(),
+ HashMap::from([("summary".into(), "persistent".into())])
+ );
+ }
+}
diff --git a/src/pane.rs b/src/pane.rs
index 19d356dc..8f71031f 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1481,6 +1481,7 @@ impl PaneRuntime {
},
keyboard_protocol_ansi: self.terminal.kitty_keyboard_state_ansi(),
input_state: self.input_state(),
+ terminal_title: self.terminal_title(),
initial_history_ansi: None,
}
}
@@ -1668,6 +1669,7 @@ impl PaneRuntime {
keyboard_protocol_flags,
keyboard_protocol_ansi,
input_state,
+ terminal_title,
initial_history_ansi,
} = state;
let pane_id = PaneId::from_raw(pane_id);
@@ -1688,6 +1690,7 @@ impl PaneRuntime {
}
let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?;
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
+ pane_terminal.seed_terminal_title(terminal_title);
if let Some(input_state) = input_state {
pane_terminal.seed_handoff_input_state(input_state);
}
@@ -2450,6 +2453,10 @@ impl PaneRuntime {
self.terminal.detection_text()
}
+ pub fn terminal_title(&self) -> Option<String> {
+ self.terminal.terminal_title()
+ }
+
pub fn agent_osc_title(&self) -> String {
self.terminal.agent_osc_title()
}
@@ -3121,16 +3128,20 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
- async fn handoff_runtime_state_captures_terminal_input_state() {
+ async fn handoff_runtime_state_captures_terminal_input_and_title_state() {
let runtime = PaneRuntime::test_with_screen_bytes(
80,
24,
b"\x1b[>5u\x1b[>4;2m\x1b[?1h\x1b[?2004h\x1b[?1004h\x1b[?1002h\x1b[?1006h",
);
+ runtime.test_process_pty_bytes("\x1b]2;✳ 修复🙂标题\x1b\\".as_bytes());
+ runtime.terminal.clear_agent_osc_state();
+ assert_eq!(runtime.agent_osc_title(), "");
let pane = runtime.handoff_runtime_state(12);
assert_eq!(pane.keyboard_protocol_flags, 5);
+ assert_eq!(pane.terminal_title.as_deref(), Some("✳ 修复🙂标题"));
assert_eq!(
pane.input_state,
Some(InputState {
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 92a743c4..838515e0 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -460,6 +460,7 @@ pub(super) struct AgentOscStateTracker {
state: Osc52ForwarderState,
body: Vec<u8>,
latest_title: Option<String>,
+ terminal_title: Option<String>,
latest_progress: Option<String>,
}
@@ -513,12 +514,14 @@ impl AgentOscStateTracker {
if let Some((command, payload)) = parse_agent_osc_body(&self.body) {
match command {
b"0" | b"2" => {
- if payload.is_empty() {
- self.latest_title = None;
+ let title = if payload.is_empty() {
+ None
} else {
- self.latest_title =
- Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS));
- }
+ let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS);
+ (!title.is_empty()).then_some(title)
+ };
+ self.latest_title.clone_from(&title);
+ self.terminal_title = title;
}
b"9" => {
self.latest_progress =
@@ -530,6 +533,15 @@ impl AgentOscStateTracker {
self.body.clear();
}
+ pub(super) fn terminal_title(&self) -> Option<&str> {
+ self.terminal_title.as_deref()
+ }
+
+ #[cfg(unix)]
+ pub(super) fn seed_terminal_title(&mut self, title: Option<String>) {
+ self.terminal_title = title;
+ }
+
/// Returns the latest retained OSC title, or `""` if none has been seen or
/// the last title was an empty clear.
#[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C
@@ -1081,6 +1093,7 @@ mod tests {
let mut t = AgentOscStateTracker::default();
t.observe("hello\x1b]0;braille title\x07world".as_bytes());
assert_eq!(t.latest_title(), "braille title");
+ assert_eq!(t.terminal_title(), Some("braille title"));
assert_eq!(t.latest_progress(), "");
}
@@ -1101,6 +1114,29 @@ mod tests {
// Then clear it with an empty payload (Codex pattern).
t.observe(b"\x1b]0;\x07");
assert_eq!(t.latest_title(), "");
+ assert_eq!(t.terminal_title(), None);
+ }
+
+ #[test]
+ fn clearing_agent_evidence_preserves_the_terminal_title() {
+ let mut tracker = AgentOscStateTracker::default();
+ tracker.observe("\x1b]2;✳ 修复🙂标题\x1b\\".as_bytes());
+
+ tracker.clear_retained();
+
+ assert_eq!(tracker.latest_title(), "");
+ assert_eq!(tracker.terminal_title(), Some("✳ 修复🙂标题"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn handoff_seed_does_not_restore_agent_detection_evidence() {
+ let mut tracker = AgentOscStateTracker::default();
+
+ tracker.seed_terminal_title(Some("✳ restored title".into()));
+
+ assert_eq!(tracker.terminal_title(), Some("✳ restored title"));
+ assert_eq!(tracker.latest_title(), "");
}
#[test]
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 5c7dfcb9..06cdea35 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -445,6 +445,10 @@ impl PaneTerminal {
.maybe_restore_host_terminal_theme(pane_id, shell_pid)
}
+ pub fn terminal_title(&self) -> Option<String> {
+ self.ghostty.terminal_title()
+ }
+
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
pub fn agent_osc_title(&self) -> String {
self.ghostty.agent_osc_title()
@@ -985,6 +989,20 @@ impl GhosttyPaneTerminal {
)
}
+ pub fn terminal_title(&self) -> Option<String> {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|core| core.agent_osc_state.terminal_title().map(str::to_string))
+ }
+
+ #[cfg(unix)]
+ pub fn seed_terminal_title(&self, title: Option<String>) {
+ if let Ok(mut core) = self.core.lock() {
+ core.agent_osc_state.seed_terminal_title(title);
+ }
+ }
+
/// Returns the latest OSC 0/2 title retained for agent detection, or `""`
/// if no title has been seen or the last update was an empty clear.
#[allow(dead_code)] // exposed for Stage C (detection loop wiring)
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 162c13ed..cd44ccf7 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -413,6 +413,8 @@ fn restore_workspace(
cached_git_ahead_behind: None,
cached_git_space: crate::workspace::git_space_metadata(&snap.identity_cwd),
worktree_space,
+ metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
+ metadata_token_sequences: HashMap::new(),
public_pane_numbers,
next_public_pane_number,
next_public_tab_number,
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index 506a4d45..f78fd836 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -1076,7 +1076,6 @@ mod tests {
"pi".into(),
crate::detect::AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
);
diff --git a/src/server/headless.rs b/src/server/headless.rs
index e6d2b833..6cec130a 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -463,6 +463,12 @@ impl HeadlessServer {
needs_render = true;
crate::render_prof::event("render.request.pty_dirty");
}
+ let terminal_title_changed = self.app.sync_terminal_titles();
+ if terminal_title_changed && self.app.terminal_title_sidebar_configured() {
+ needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.terminal_title");
+ }
// 2. Drain a bounded internal-event batch. API handlers perform an
// exhaustive forwarding-aware drain before reading pane/runtime state.
@@ -471,6 +477,11 @@ impl HeadlessServer {
needs_full_render = true;
crate::render_prof::event("full_render_cause.internal_events");
}
+ if self.app.expire_due_metadata(Instant::now()) {
+ needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.metadata_expiry");
+ }
// 3. Drain API requests.
if self.drain_api_requests_with_shutdown_check() {
@@ -2789,6 +2800,8 @@ impl HeadlessServer {
return false;
}
+ let metadata_expired = self.app.expire_due_metadata(Instant::now());
+
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
let response = match self.perform_live_handoff(params.clone()) {
Ok(()) => serde_json::to_string(&api::schema::SuccessResponse {
@@ -2832,7 +2845,7 @@ impl HeadlessServer {
_ => {}
}
- let mut changed = api::request_changes_ui(&msg.request);
+ let mut changed = metadata_expired | api::request_changes_ui(&msg.request);
let skip_default_workspace = skip_default_workspace_for_request
|| matches!(
&msg.request.method,
@@ -3713,13 +3726,7 @@ impl HeadlessServer {
.agent_metadata_deadline
.filter(|deadline| now >= *deadline)
{
- let previous_toast = self.app.state.toast.clone();
- for update in self.app.state.expire_agent_metadata_at(deadline, now) {
- self.app
- .refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
- self.app.emit_pane_state_update(&update);
- }
- self.app.sync_agent_metadata_deadline();
+ self.app.expire_metadata_at(deadline, now);
changed = true;
}
@@ -4260,6 +4267,91 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn headless_api_reads_latest_title_without_spinner_event_flooding() {
+ let event_hub = api::EventHub::default();
+ let mut server = test_headless_server_with_event_hub(event_hub.clone());
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ server.app.state.sidebar_agents.rows = vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]];
+ let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(crate::detect::Agent::Claude);
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07");
+ server
+ .app
+ .terminal_runtimes
+ .insert(terminal_id.clone(), runtime);
+
+ let first = headless_pane_list(&mut server).pop().unwrap();
+ assert_eq!(first.terminal_title.as_deref(), Some("⠋ task"));
+ assert_eq!(first.terminal_title_stripped.as_deref(), Some("task"));
+ assert_eq!(pane_updated_events(&event_hub), 1);
+ let (buffer, _) = crate::server::render_stream::render_virtual_with_runtime_registry(
+ &mut server.app.state,
+ &server.app.terminal_runtimes,
+ Rect::new(0, 0, 100, 30),
+ true,
+ crate::kitty_graphics::HostCellSize::default(),
+ );
+ let rendered = buffer
+ .content
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(rendered.contains("task"), "rendered frame: {rendered:?}");
+
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\");
+ let second = headless_pane_list(&mut server).pop().unwrap();
+ assert_eq!(second.terminal_title.as_deref(), Some("⠙ task"));
+ assert_eq!(second.terminal_title_stripped.as_deref(), Some("task"));
+ assert_eq!(pane_updated_events(&event_hub), 1);
+ }
+
+ fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> {
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "list-titles".into(),
+ method: api::schema::Method::PaneList(api::schema::PaneListParams::default()),
+ },
+ respond_to,
+ });
+ let response: api::schema::SuccessResponse =
+ serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
+ let api::schema::ResponseResult::PaneList { panes } = response.result else {
+ panic!("expected pane list");
+ };
+ panes
+ }
+
+ fn pane_updated_events(event_hub: &api::EventHub) -> usize {
+ event_hub
+ .events_after(0)
+ .iter()
+ .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated)
+ .count()
+ }
+
#[test]
fn headless_api_request_drains_all_pending_internal_events_before_reading_state() {
let mut server = test_headless_server();
@@ -5431,7 +5523,6 @@ next_tab = ""
agent_label: "pi".into(),
state: crate::detect::AgentState::Working,
message: None,
- custom_status: None,
seq: None,
session_ref: None,
})
@@ -5442,13 +5533,11 @@ next_tab = ""
source: "user:pi-display".into(),
agent_label: Some("pi".into()),
applies_to_source: Some("herdr:pi".into()),
- title: None,
+ title: Some("short lived".into()),
display_agent: None,
- custom_status: Some("short lived".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
seq: None,
ttl: Some(Duration::from_millis(1)),
@@ -5471,7 +5560,7 @@ next_tab = ""
.terminals
.get(&terminal_id)
.expect("terminal")
- .effective_custom_status()
+ .effective_title()
.as_deref(),
Some("short lived")
);
@@ -5486,7 +5575,7 @@ next_tab = ""
.terminals
.get(&terminal_id)
.expect("terminal")
- .effective_custom_status(),
+ .effective_title(),
None
);
assert!(server
@@ -5499,9 +5588,9 @@ next_tab = ""
&& matches!(
&event.data,
crate::api::schema::EventData::PaneAgentStatusChanged {
- custom_status,
+ title,
..
- } if custom_status.is_none()
+ } if title.is_none()
)
}));
}
@@ -8587,7 +8676,6 @@ next_tab = ""
agent: "pi".into(),
state: api::schema::PaneAgentState::Idle,
message: None,
- custom_status: None,
seq: Some(19),
agent_session_id: None,
agent_session_path: None,
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs
index 2666b32f..225d609a 100644
--- a/src/terminal/metadata.rs
+++ b/src/terminal/metadata.rs
@@ -12,12 +12,10 @@ pub struct AgentMetadata {
pub applies_to_source: Option<String>,
pub title: Option<String>,
pub display_agent: Option<String>,
- pub custom_status: Option<String>,
pub state_labels: HashMap<String, String>,
pub reported_at: Instant,
title_reported_at: Option<Instant>,
display_agent_reported_at: Option<Instant>,
- custom_status_reported_at: Option<Instant>,
state_label_reported_at: HashMap<String, Instant>,
pub ttl: Option<Duration>,
expiry_event_pending: bool,
@@ -30,11 +28,9 @@ pub struct AgentMetadataReport {
pub applies_to_source: Option<String>,
pub title: Option<String>,
pub display_agent: Option<String>,
- pub custom_status: Option<String>,
pub state_labels: HashMap<String, String>,
pub clear_title: bool,
pub clear_display_agent: bool,
- pub clear_custom_status: bool,
pub clear_state_labels: bool,
pub ttl: Option<Duration>,
pub seq: Option<u64>,
@@ -44,7 +40,6 @@ pub struct AgentMetadataReport {
pub struct EffectivePresentation {
pub title: Option<String>,
pub display_agent: Option<String>,
- pub custom_status: Option<String>,
pub state_labels: HashMap<String, String>,
}
@@ -53,36 +48,67 @@ impl EffectivePresentation {
Self {
title: None,
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
}
}
}
impl TerminalState {
- fn accept_metadata_report(&mut self, source: &str, seq: Option<u64>) -> bool {
+ pub(crate) fn metadata_report_sequence_is_fresh(&self, source: &str, seq: Option<u64>) -> bool {
+ crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq)
+ }
+
+ pub(crate) fn accept_metadata_report(
+ &mut self,
+ source: &str,
+ seq: Option<u64>,
+ includes_tokens: bool,
+ ) -> Result<bool, ()> {
let Some(seq) = seq else {
- return true;
+ return Ok(true);
};
-
- if self
- .metadata_report_sequences
- .get(source)
- .is_some_and(|last_seq| seq <= *last_seq)
+ if !self.metadata_report_sequence_is_fresh(source, Some(seq)) {
+ return Ok(false);
+ }
+ if includes_tokens
+ && !self.metadata_token_sequence_sources.contains(source)
+ && self.metadata_token_sequence_sources.len()
+ >= crate::metadata_tokens::MAX_SEQUENCE_SOURCES
{
- return false;
+ return Err(());
}
-
self.metadata_report_sequences
.insert(source.to_string(), seq);
- true
+ if includes_tokens {
+ self.metadata_token_sequence_sources
+ .insert(source.to_string());
+ }
+ Ok(true)
+ }
+
+ fn metadata_guards_match(
+ &self,
+ agent_label: Option<&str>,
+ applies_to_source: Option<&str>,
+ ) -> bool {
+ if agent_label.is_some_and(|agent| self.effective_agent_label() != Some(agent)) {
+ return false;
+ }
+ !applies_to_source.is_some_and(|source| {
+ self.hook_authority
+ .as_ref()
+ .is_none_or(|authority| authority.source != source)
+ })
}
pub fn set_agent_metadata(
&mut self,
report: AgentMetadataReport,
) -> Option<TerminalStateMutation> {
- if !self.accept_metadata_report(&report.source, report.seq) {
+ if !matches!(
+ self.accept_metadata_report(&report.source, report.seq, false),
+ Ok(true)
+ ) {
return None;
}
@@ -100,17 +126,12 @@ impl TerminalState {
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let has_set_fields = report.title.is_some()
|| report.display_agent.is_some()
- || report.custom_status.is_some()
|| !report.state_labels.is_empty();
let report_source = report.source.clone();
let report_has_ttl = report.ttl.is_some();
- if report.clear_title
- || report.clear_display_agent
- || report.clear_custom_status
- || report.clear_state_labels
- {
+ if report.clear_title || report.clear_display_agent || report.clear_state_labels {
let metadata = self
.agent_metadata
.entry(report.source.clone())
@@ -120,12 +141,10 @@ impl TerminalState {
applies_to_source: report.applies_to_source.clone(),
title: None,
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
reported_at: now,
title_reported_at: None,
display_agent_reported_at: None,
- custom_status_reported_at: None,
state_label_reported_at: HashMap::new(),
ttl: report.ttl,
expiry_event_pending: false,
@@ -138,10 +157,6 @@ impl TerminalState {
metadata.display_agent = None;
metadata.display_agent_reported_at = None;
}
- if report.clear_custom_status {
- metadata.custom_status = None;
- metadata.custom_status_reported_at = None;
- }
if report.clear_state_labels {
metadata.state_labels.clear();
metadata.state_label_reported_at.clear();
@@ -160,10 +175,6 @@ impl TerminalState {
metadata.display_agent = Some(display_agent);
metadata.display_agent_reported_at = Some(now);
}
- if let Some(custom_status) = report.custom_status {
- metadata.custom_status = Some(custom_status);
- metadata.custom_status_reported_at = Some(now);
- }
for (state, label) in report.state_labels {
metadata.state_labels.insert(state.clone(), label);
metadata.state_label_reported_at.insert(state, now);
@@ -176,7 +187,6 @@ impl TerminalState {
} else {
let title_reported_at = report.title.as_ref().map(|_| now);
let display_agent_reported_at = report.display_agent.as_ref().map(|_| now);
- let custom_status_reported_at = report.custom_status.as_ref().map(|_| now);
let state_label_reported_at = report
.state_labels
.keys()
@@ -190,12 +200,10 @@ impl TerminalState {
applies_to_source: report.applies_to_source,
title: report.title,
display_agent: report.display_agent,
- custom_status: report.custom_status,
state_labels: report.state_labels,
reported_at: now,
title_reported_at,
display_agent_reported_at,
- custom_status_reported_at,
state_label_reported_at,
ttl: report.ttl,
expiry_event_pending: false,
@@ -231,11 +239,6 @@ impl TerminalState {
session_ref_changed: false,
})
}
- pub fn effective_custom_status(&self) -> Option<String> {
- self.effective_presentation_for_state_at(self.state, Instant::now())
- .custom_status
- }
-
pub fn effective_title(&self) -> Option<String> {
self.effective_presentation_for_state_at(self.state, Instant::now())
.title
@@ -337,7 +340,7 @@ impl TerminalState {
fn effective_presentation_for_state_at_with_ttl(
&self,
- state: AgentState,
+ _state: AgentState,
now: Instant,
enforce_ttl: bool,
) -> EffectivePresentation {
@@ -345,30 +348,9 @@ impl TerminalState {
presentation.title = self.newest_metadata_title(now, enforce_ttl);
presentation.display_agent = self.newest_metadata_display_agent(now, enforce_ttl);
presentation.state_labels = self.effective_metadata_state_labels(now, enforce_ttl);
- presentation.custom_status =
- self.effective_custom_status_for_state_at_with_ttl(state, now, enforce_ttl);
presentation
}
- fn effective_custom_status_for_state_at_with_ttl(
- &self,
- _state: AgentState,
- now: Instant,
- enforce_ttl: bool,
- ) -> Option<String> {
- if let Some(custom_status) = self.newest_metadata_custom_status(now, enforce_ttl) {
- return Some(custom_status);
- }
-
- if self.visible_blocker_overrides_hook() {
- return None;
- }
-
- self.hook_authority
- .as_ref()
- .and_then(|authority| authority.custom_status.clone())
- }
-
fn valid_agent_metadata(
&self,
now: Instant,
@@ -393,13 +375,6 @@ impl TerminalState {
.and_then(|metadata| metadata.display_agent.clone())
}
- fn newest_metadata_custom_status(&self, now: Instant, enforce_ttl: bool) -> Option<String> {
- self.valid_agent_metadata(now, enforce_ttl)
- .filter(|metadata| metadata.custom_status.is_some())
- .max_by_key(|metadata| metadata.custom_status_reported_at)
- .and_then(|metadata| metadata.custom_status.clone())
- }
-
fn effective_metadata_state_labels(
&self,
now: Instant,
@@ -432,7 +407,6 @@ impl TerminalState {
) -> bool {
if metadata.title.is_none()
&& metadata.display_agent.is_none()
- && metadata.custom_status.is_none()
&& metadata.state_labels.is_empty()
{
return false;
@@ -446,7 +420,6 @@ impl TerminalState {
fn agent_metadata_is_visible_ignoring_ttl(&self, metadata: &AgentMetadata) -> bool {
(metadata.title.is_some()
|| metadata.display_agent.is_some()
- || metadata.custom_status.is_some()
|| !metadata.state_labels.is_empty())
&& self.agent_metadata_matches_guards(metadata)
}
@@ -483,21 +456,10 @@ impl TerminalState {
}
fn agent_metadata_matches_guards(&self, metadata: &AgentMetadata) -> bool {
- if metadata
- .agent_label
- .as_deref()
- .is_some_and(|agent| self.effective_agent_label() != Some(agent))
- {
- return false;
- }
- if metadata.applies_to_source.as_deref().is_some_and(|source| {
- self.hook_authority
- .as_ref()
- .is_none_or(|authority| authority.source != source)
- }) {
- return false;
- }
- true
+ self.metadata_guards_match(
+ metadata.agent_label.as_deref(),
+ metadata.applies_to_source.as_deref(),
+ )
}
}
@@ -513,129 +475,39 @@ mod tests {
TerminalState::new(TerminalId::alloc(), "/tmp".into())
}
- fn set_metadata_custom_status(
- terminal: &mut TerminalState,
- source: &str,
- agent_label: Option<&str>,
- applies_to_source: Option<&str>,
- custom_status: Option<&str>,
- clear_custom_status: bool,
- ) -> Option<TerminalStateMutation> {
- terminal.set_agent_metadata(AgentMetadataReport {
- source: source.into(),
- agent_label: agent_label.map(str::to_string),
- applies_to_source: applies_to_source.map(str::to_string),
- title: None,
- display_agent: None,
- custom_status: custom_status.map(str::to_string),
- state_labels: HashMap::new(),
- clear_title: false,
- clear_display_agent: false,
- clear_custom_status,
- clear_state_labels: false,
- ttl: None,
- seq: None,
- })
- }
-
- #[test]
- fn user_agent_metadata_overrides_hook_custom_status_without_changing_state() {
- let mut terminal = test_terminal();
- terminal.set_hook_authority_with_custom_status(
- "herdr:claude".into(),
- "claude".into(),
- AgentState::Working,
- None,
- Some("thinking".into()),
- None,
- );
-
- let mutation = set_metadata_custom_status(
- &mut terminal,
- "user:claude-title",
- Some("claude"),
- Some("herdr:claude"),
- Some("refactor auth"),
- false,
- );
-
- assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("refactor auth")
- );
- let change = mutation.unwrap().effective_state_change.unwrap();
- assert_eq!(change.previous_state, AgentState::Working);
- assert_eq!(change.state, AgentState::Working);
- assert_eq!(
- change.previous_presentation.custom_status.as_deref(),
- Some("thinking")
- );
- assert_eq!(
- change.presentation.custom_status.as_deref(),
- Some("refactor auth")
- );
- }
-
- #[test]
- fn user_agent_metadata_requires_matching_lifecycle_source() {
- let mut terminal = test_terminal();
- terminal.set_hook_authority(
- "herdr:codex".into(),
- "codex".into(),
- AgentState::Working,
- None,
- None,
- );
- set_metadata_custom_status(
- &mut terminal,
- "user:claude-title",
- Some("claude"),
- Some("herdr:claude"),
- Some("refactor auth"),
- false,
- );
-
- assert_eq!(terminal.effective_custom_status(), None);
- }
-
#[test]
- fn clearing_user_agent_metadata_restores_hook_custom_status() {
+ fn presentation_sequences_remain_unbounded_while_token_sequences_are_bounded() {
let mut terminal = test_terminal();
- terminal.set_hook_authority_with_custom_status(
- "herdr:claude".into(),
- "claude".into(),
- AgentState::Working,
- None,
- Some("thinking".into()),
- None,
- );
- set_metadata_custom_status(
- &mut terminal,
- "user:claude-title",
- Some("claude"),
- Some("herdr:claude"),
- Some("refactor auth"),
- false,
- );
-
- set_metadata_custom_status(&mut terminal, "user:claude-title", None, None, None, true);
-
+ for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
+ assert_eq!(
+ terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false),
+ Ok(true)
+ );
+ }
+ for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
+ assert_eq!(
+ terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true),
+ Ok(true)
+ );
+ }
assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("thinking")
+ terminal.accept_metadata_report(
+ &format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES),
+ Some(2),
+ true,
+ ),
+ Err(())
);
}
#[test]
fn user_agent_metadata_overrides_presentation_fields_only() {
let mut terminal = test_terminal();
- terminal.set_hook_authority_with_custom_status(
+ terminal.set_hook_authority(
"herdr:claude".into(),
"claude".into(),
AgentState::Working,
None,
- Some("thinking".into()),
None,
);
@@ -645,11 +517,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: Some("Refactor auth".into()),
display_agent: Some("Claude: auth".into()),
- custom_status: Some("middleware".into()),
state_labels: HashMap::from([("working".into(), "deep in the mines".into())]),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: None,
@@ -660,7 +530,6 @@ mod tests {
let presentation = terminal.effective_presentation();
assert_eq!(presentation.title.as_deref(), Some("Refactor auth"));
assert_eq!(presentation.display_agent.as_deref(), Some("Claude: auth"));
- assert_eq!(presentation.custom_status.as_deref(), Some("middleware"));
assert_eq!(
presentation.state_labels.get("working").map(String::as_str),
Some("deep in the mines")
@@ -679,11 +548,9 @@ mod tests {
applies_to_source: None,
title: Some("Prompt title".into()),
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: None,
@@ -707,27 +574,23 @@ mod tests {
None,
);
- set_metadata_custom_status(
- &mut terminal,
- "user:claude-title",
- Some("claude"),
- Some("herdr:claude"),
- Some("first"),
- false,
- );
- set_metadata_custom_status(
- &mut terminal,
- "user:claude-title",
- Some("claude"),
- Some("herdr:claude"),
- Some("second"),
- false,
- );
+ for title in ["first", "second"] {
+ terminal.set_agent_metadata(AgentMetadataReport {
+ source: "user:claude-title".into(),
+ agent_label: Some("claude".into()),
+ applies_to_source: Some("herdr:claude".into()),
+ title: Some(title.into()),
+ display_agent: None,
+ state_labels: HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_state_labels: false,
+ ttl: None,
+ seq: None,
+ });
+ }
- assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("second")
- );
+ assert_eq!(terminal.effective_title().as_deref(), Some("second"));
}
#[test]
@@ -746,11 +609,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: Some("Prompt title".into()),
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: Some(1),
@@ -760,12 +621,10 @@ mod tests {
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
title: None,
- display_agent: None,
- custom_status: Some("activity".into()),
+ display_agent: Some("Claude activity".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: Some(1),
@@ -773,7 +632,10 @@ mod tests {
let presentation = terminal.effective_presentation();
assert_eq!(presentation.title.as_deref(), Some("Prompt title"));
- assert_eq!(presentation.custom_status.as_deref(), Some("activity"));
+ assert_eq!(
+ presentation.display_agent.as_deref(),
+ Some("Claude activity")
+ );
}
#[test]
@@ -792,26 +654,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: None,
display_agent: Some("First display".into()),
- custom_status: Some("old".into()),
- state_labels: HashMap::new(),
- clear_title: false,
- clear_display_agent: false,
- clear_custom_status: false,
- clear_state_labels: false,
- ttl: None,
- seq: Some(1),
- });
- terminal.set_agent_metadata(AgentMetadataReport {
- source: "user:second".into(),
- agent_label: Some("claude".into()),
- applies_to_source: Some("herdr:claude".into()),
- title: None,
- display_agent: None,
- custom_status: Some("new".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: Some(1),
@@ -822,11 +667,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: Some("Fresh title".into()),
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: true,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: Some(2),
@@ -835,40 +678,6 @@ mod tests {
let presentation = terminal.effective_presentation();
assert_eq!(presentation.title.as_deref(), Some("Fresh title"));
assert_eq!(presentation.display_agent, None);
- assert_eq!(presentation.custom_status.as_deref(), Some("new"));
- }
-
- #[test]
- fn metadata_can_set_other_fields_while_clearing_missing_source() {
- let mut terminal = test_terminal();
- terminal.set_hook_authority(
- "herdr:claude".into(),
- "claude".into(),
- AgentState::Working,
- None,
- None,
- );
-
- terminal.set_agent_metadata(AgentMetadataReport {
- source: "user:status".into(),
- agent_label: Some("claude".into()),
- applies_to_source: Some("herdr:claude".into()),
- title: None,
- display_agent: None,
- custom_status: Some("activity".into()),
- state_labels: HashMap::new(),
- clear_title: true,
- clear_display_agent: false,
- clear_custom_status: false,
- clear_state_labels: false,
- ttl: None,
- seq: None,
- });
-
- assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("activity")
- );
}
#[test]
@@ -887,11 +696,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: Some("Old title".into()),
display_agent: None,
- custom_status: Some("old".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::from_millis(1)),
seq: None,
@@ -902,27 +709,25 @@ mod tests {
source: "user:status".into(),
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
- title: None,
+ title: Some("Fresh title".into()),
display_agent: None,
- custom_status: Some("fresh".into()),
state_labels: HashMap::new(),
- clear_title: true,
+ clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: None,
seq: None,
});
assert_eq!(terminal.next_agent_metadata_expiry(), None);
- assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh"));
+ assert_eq!(terminal.effective_title().as_deref(), Some("Fresh title"));
assert!(terminal
.expire_agent_metadata_at(
old_deadline + Duration::from_millis(1),
old_deadline + Duration::from_millis(1)
)
.is_none());
- assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh"));
+ assert_eq!(terminal.effective_title().as_deref(), Some("Fresh title"));
}
#[test]
@@ -940,12 +745,10 @@ mod tests {
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
title: Some("Prompt title".into()),
- display_agent: None,
- custom_status: Some("old".into()),
+ display_agent: Some("Old display".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::from_millis(1)),
seq: None,
@@ -958,11 +761,9 @@ mod tests {
applies_to_source: None,
title: None,
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
- clear_display_agent: false,
- clear_custom_status: true,
+ clear_display_agent: true,
clear_state_labels: false,
ttl: None,
seq: None,
@@ -973,7 +774,7 @@ mod tests {
terminal.effective_presentation().title.as_deref(),
Some("Prompt title")
);
- assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(terminal.effective_presentation().display_agent, None);
let mutation = terminal
.expire_agent_metadata_at(old_deadline, old_deadline)
@@ -996,13 +797,11 @@ mod tests {
source: "user:status".into(),
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
- title: None,
+ title: Some("Activity".into()),
display_agent: None,
- custom_status: Some("activity".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::from_millis(1)),
seq: None,
@@ -1017,11 +816,11 @@ mod tests {
assert_eq!(change.previous_state, AgentState::Working);
assert_eq!(change.state, AgentState::Working);
assert_eq!(
- change.previous_presentation.custom_status.as_deref(),
- Some("activity")
+ change.previous_presentation.title.as_deref(),
+ Some("Activity")
);
- assert_eq!(change.presentation.custom_status, None);
- assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(change.presentation.title, None);
+ assert_eq!(terminal.effective_title(), None);
}
#[test]
@@ -1038,13 +837,11 @@ mod tests {
source: "user:status".into(),
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
- title: None,
+ title: Some("Stale".into()),
display_agent: None,
- custom_status: Some("stale".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::from_millis(1)),
seq: None,
@@ -1055,7 +852,7 @@ mod tests {
.and_then(|metadata| terminal.agent_metadata_expiry(metadata))
.unwrap();
assert_eq!(terminal.next_agent_metadata_expiry(), None);
- assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(terminal.effective_title(), None);
terminal.set_hook_authority(
"herdr:claude".into(),
@@ -1071,7 +868,7 @@ mod tests {
assert!(mutation.is_none());
assert!(!terminal.agent_metadata.contains_key("user:status"));
- assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(terminal.effective_title(), None);
}
#[test]
@@ -1090,11 +887,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: Some("First".into()),
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::from_millis(1)),
seq: None,
@@ -1106,11 +901,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: None,
display_agent: Some("Second".into()),
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::from_millis(2)),
seq: None,
@@ -1149,13 +942,11 @@ mod tests {
source: "user:status".into(),
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
- title: None,
+ title: Some("Instant".into()),
display_agent: None,
- custom_status: Some("instant".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::ZERO),
seq: None,
@@ -1170,11 +961,11 @@ mod tests {
let change = mutation.effective_state_change.unwrap();
assert_eq!(
- change.previous_presentation.custom_status.as_deref(),
- Some("instant")
+ change.previous_presentation.title.as_deref(),
+ Some("Instant")
);
- assert_eq!(change.presentation.custom_status, None);
- assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(change.presentation.title, None);
+ assert_eq!(terminal.effective_title(), None);
}
#[test]
@@ -1191,13 +982,11 @@ mod tests {
source: "user:status".into(),
agent_label: Some("claude".into()),
applies_to_source: Some("herdr:claude".into()),
- title: None,
+ title: Some("Instant".into()),
display_agent: None,
- custom_status: Some("instant".into()),
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::ZERO),
seq: None,
@@ -1220,7 +1009,7 @@ mod tests {
);
assert_eq!(terminal.next_agent_metadata_expiry(), None);
- assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(terminal.effective_title(), None);
}
#[test]
@@ -1239,11 +1028,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: Some("Expired title".into()),
display_agent: None,
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: false,
clear_state_labels: false,
ttl: Some(Duration::ZERO),
seq: None,
@@ -1256,11 +1043,9 @@ mod tests {
applies_to_source: Some("herdr:claude".into()),
title: None,
display_agent: Some("Fresh display".into()),
- custom_status: None,
state_labels: HashMap::new(),
clear_title: false,
clear_display_agent: false,
- clear_custom_status: true,
clear_state_labels: false,
ttl: None,
seq: None,
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 03432a56..a67b7bae 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -2,6 +2,7 @@ mod id;
mod runtime;
mod runtime_registry;
pub mod state;
+mod title;
pub use id::TerminalId;
pub use runtime::TerminalRuntime;
@@ -10,3 +11,4 @@ pub use state::{
AgentMetadataReport, EffectivePresentation, EffectiveStateChange, TerminalState,
TerminalStateMutation,
};
+pub(crate) use title::stripped_terminal_title;
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 1ff799b8..29d19140 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -307,6 +307,10 @@ impl TerminalRuntime {
self.0.detection_text()
}
+ pub fn terminal_title(&self) -> Option<String> {
+ self.0.terminal_title()
+ }
+
pub fn agent_osc_title(&self) -> String {
self.0.agent_osc_title()
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 2b17404a..632558fd 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -22,7 +22,6 @@ pub struct HookAuthority {
pub agent_label: String,
pub state: AgentState,
pub message: Option<String>,
- pub custom_status: Option<String>,
pub reported_at: Instant,
pub session_ref: Option<crate::agent_resume::AgentSessionRef>,
}
@@ -59,6 +58,12 @@ pub struct EffectiveStateChange {
pub presentation: EffectivePresentation,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub(crate) struct TerminalTitleChange {
+ pub(crate) raw_changed: bool,
+ pub(crate) stripped_changed: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TerminalStateMutation {
pub effective_state_change: Option<EffectiveStateChange>,
@@ -79,13 +84,16 @@ pub struct TerminalState {
fallback_observed_at: Option<Instant>,
pub hook_authority: Option<HookAuthority>,
pub agent_metadata: HashMap<String, AgentMetadata>,
+ pub metadata_tokens: crate::metadata_tokens::MetadataTokens,
pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>,
+ pub terminal_title: Option<String>,
pub manual_label: Option<String>,
pub agent_name: Option<String>,
hook_report_sequences: HashMap<String, u64>,
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>,
metadata_report_sequences: HashMap<String, u64>,
+ metadata_token_sequence_sources: std::collections::HashSet<String>,
pub state: AgentState,
pub last_agent_state_change_seq: Option<u64>,
pub revision: u64,
@@ -106,13 +114,16 @@ impl TerminalState {
fallback_observed_at: None,
hook_authority: None,
agent_metadata: HashMap::new(),
+ metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
persisted_agent_session: None,
+ terminal_title: None,
manual_label: None,
agent_name: None,
hook_report_sequences: HashMap::new(),
suppressed_full_lifecycle_hook_reports: HashMap::new(),
stale_full_lifecycle_hook_sessions: HashMap::new(),
metadata_report_sequences: HashMap::new(),
+ metadata_token_sequence_sources: std::collections::HashSet::new(),
state: AgentState::Unknown,
last_agent_state_change_seq: None,
revision: 0,
@@ -123,6 +134,28 @@ impl TerminalState {
}
}
+ pub(crate) fn terminal_title_stripped(&self) -> Option<String> {
+ self.terminal_title
+ .as_deref()
+ .and_then(super::stripped_terminal_title)
+ }
+
+ pub(crate) fn set_terminal_title(&mut self, title: Option<String>) -> TerminalTitleChange {
+ if self.terminal_title == title {
+ return TerminalTitleChange::default();
+ }
+ let previous_stripped = self.terminal_title_stripped();
+ self.terminal_title = title;
+ let stripped_changed = previous_stripped != self.terminal_title_stripped();
+ if stripped_changed {
+ self.revision = self.revision.wrapping_add(1);
+ }
+ TerminalTitleChange {
+ raw_changed: true,
+ stripped_changed,
+ }
+ }
+
pub fn with_launch_argv(mut self, argv: Vec<String>) -> Self {
self.launch_argv = Some(argv);
self
@@ -331,25 +364,11 @@ impl TerminalState {
message: Option<String>,
seq: Option<u64>,
) -> Option<EffectiveStateChange> {
- self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq)
- }
-
- #[cfg(test)]
- pub fn set_hook_authority_with_custom_status(
- &mut self,
- source: String,
- agent_label: String,
- state: AgentState,
- message: Option<String>,
- custom_status: Option<String>,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- self.set_hook_authority_with_custom_status_at(
+ self.set_hook_authority_at(
source,
agent_label,
state,
message,
- custom_status,
None,
seq,
Instant::now(),
@@ -363,29 +382,26 @@ impl TerminalState {
agent_label: String,
state: AgentState,
message: Option<String>,
- custom_status: Option<String>,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
seq: Option<u64>,
) -> Option<TerminalStateMutation> {
- self.set_hook_authority_with_custom_status_at(
+ self.set_hook_authority_at(
source,
agent_label,
state,
message,
- custom_status,
session_ref,
seq,
Instant::now(),
)
}
- pub fn set_hook_authority_with_custom_status_at(
+ pub fn set_hook_authority_at(
&mut self,
source: String,
agent_label: String,
state: AgentState,
message: Option<String>,
- custom_status: Option<String>,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
seq: Option<u64>,
now: Instant,
@@ -476,7 +492,6 @@ impl TerminalState {
agent_label,
state,
message,
- custom_status,
reported_at: now,
session_ref,
});
@@ -1524,7 +1539,6 @@ mod tests {
"pi".into(),
AgentState::Idle,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session),
Some(10),
);
@@ -1548,7 +1562,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(new_session.clone()),
Some(12),
);
@@ -1576,7 +1589,6 @@ mod tests {
"pi".into(),
AgentState::Idle,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_a.clone()),
Some(10),
);
@@ -1593,7 +1605,6 @@ mod tests {
"pi".into(),
AgentState::Idle,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_b.clone()),
Some(12),
);
@@ -1610,7 +1621,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_a.clone()),
Some(14),
);
@@ -1628,7 +1638,6 @@ mod tests {
"pi".into(),
AgentState::Idle,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_b),
Some(15),
);
@@ -1681,7 +1690,6 @@ mod tests {
"pi".into(),
AgentState::Idle,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session.clone()),
Some(10),
);
@@ -1698,7 +1706,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(new_session),
Some(12),
);
@@ -1725,7 +1732,6 @@ mod tests {
"omp".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session.clone()),
Some(10),
);
@@ -1754,7 +1760,6 @@ mod tests {
"omp".into(),
AgentState::Blocked,
Some("waiting".into()),
- None,
crate::agent_resume::AgentSessionRef::path(new_session.clone()),
Some(12),
);
@@ -1771,7 +1776,6 @@ mod tests {
"omp".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session),
Some(13),
);
@@ -1785,13 +1789,12 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- None,
Some(10),
now,
);
@@ -1813,13 +1816,12 @@ mod tests {
AgentState::Working
);
- let stale = terminal.set_hook_authority_with_custom_status_at(
+ let stale = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- None,
Some(9),
now + Duration::from_millis(2),
);
@@ -1833,13 +1835,12 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- None,
Some(20),
now,
);
@@ -1853,12 +1854,11 @@ mod tests {
true,
now + Duration::from_millis(1),
);
- let late = terminal.set_hook_authority_with_custom_status_at(
+ let late = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- Some("late".into()),
None,
Some(21),
now + Duration::from_millis(2),
@@ -1881,7 +1881,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
);
@@ -1900,7 +1899,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- Some("late".into()),
crate::agent_resume::AgentSessionRef::path(session_path),
Some(21),
);
@@ -1946,7 +1944,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
);
@@ -1957,7 +1954,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path),
Some(22),
);
@@ -1976,7 +1972,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
Some(20),
);
@@ -1987,7 +1982,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
Some(22),
);
@@ -2006,7 +2000,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
Some(1000),
);
@@ -2017,7 +2010,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
Some(1500),
);
@@ -2039,7 +2031,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_a.clone()),
Some(1000),
);
@@ -2050,7 +2041,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_b),
Some(1500),
);
@@ -2062,7 +2052,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- Some("late".into()),
crate::agent_resume::AgentSessionRef::path(session_a),
Some(2500),
);
@@ -2071,7 +2060,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_c),
Some(2500),
);
@@ -2091,7 +2079,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")),
Some(20),
);
@@ -2101,7 +2088,6 @@ mod tests {
"pi".into(),
AgentState::Idle,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")),
Some(21),
);
@@ -2219,7 +2205,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session.clone()),
Some(1000),
);
@@ -2259,7 +2244,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- Some("late".into()),
crate::agent_resume::AgentSessionRef::path(old_session),
Some(500),
);
@@ -2268,7 +2252,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(new_session),
Some(500),
);
@@ -2286,12 +2269,11 @@ mod tests {
let new_session = test_session_path("new-after-process-exit.jsonl");
let now = Instant::now();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session),
Some(1000),
now,
@@ -2306,12 +2288,11 @@ mod tests {
now + Duration::from_millis(1),
);
- let early_new = terminal.set_hook_authority_with_custom_status_at(
+ let early_new = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(new_session.clone()),
Some(500),
now + Duration::from_millis(2),
@@ -2338,12 +2319,11 @@ mod tests {
false,
now + Duration::from_millis(4),
);
- let fresh_new = terminal.set_hook_authority_with_custom_status_at(
+ let fresh_new = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(new_session),
Some(500),
now + Duration::from_millis(5),
@@ -2360,12 +2340,11 @@ mod tests {
let old_session = test_session_path("old-before-nosession-process-exit.jsonl");
let now = Instant::now();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(old_session),
Some(1000),
now,
@@ -2380,13 +2359,12 @@ mod tests {
now + Duration::from_millis(1),
);
- let early_without_session = terminal.set_hook_authority_with_custom_status_at(
+ let early_without_session = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- None,
Some(500),
now + Duration::from_millis(2),
);
@@ -2412,13 +2390,12 @@ mod tests {
false,
now + Duration::from_millis(4),
);
- let fresh_without_session = terminal.set_hook_authority_with_custom_status_at(
+ let fresh_without_session = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
- None,
Some(500),
now + Duration::from_millis(5),
);
@@ -2436,12 +2413,11 @@ mod tests {
let session_c = test_session_path("generation-c.jsonl");
let now = Instant::now();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_a.clone()),
Some(1000),
now,
@@ -2474,12 +2450,11 @@ mod tests {
false,
now + Duration::from_millis(3),
);
- let generation_b = terminal.set_hook_authority_with_custom_status_at(
+ let generation_b = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_b),
Some(500),
now + Duration::from_millis(4),
@@ -2514,22 +2489,20 @@ mod tests {
now + Duration::from_millis(7),
);
- let late_generation_a = terminal.set_hook_authority_with_custom_status_at(
+ let late_generation_a = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- Some("late".into()),
crate::agent_resume::AgentSessionRef::path(session_a),
Some(250),
now + Duration::from_millis(8),
);
- let generation_c = terminal.set_hook_authority_with_custom_status_at(
+ let generation_c = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_c),
Some(250),
now + Duration::from_millis(9),
@@ -2596,7 +2569,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("fresh-session"),
Some(22),
);
@@ -2638,7 +2610,6 @@ mod tests {
"mastracode".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("mastracode-new"),
Some(20),
)
@@ -2669,7 +2640,6 @@ mod tests {
agent_label.into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id(&old_session),
Some(20),
);
@@ -2682,7 +2652,6 @@ mod tests {
agent_label.into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id(&old_session),
Some(22),
);
@@ -2695,7 +2664,6 @@ mod tests {
agent_label.into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id(&new_session),
Some(23),
);
@@ -2709,12 +2677,11 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:omp".into(),
"omp".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("omp-old"),
Some(1000),
now,
@@ -2734,7 +2701,6 @@ mod tests {
"omp".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("omp-old"),
Some(500),
);
@@ -2764,7 +2730,6 @@ mod tests {
"omp".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("omp-new"),
Some(500),
);
@@ -2898,41 +2863,15 @@ mod tests {
}
#[test]
- fn visible_blocker_suppresses_stale_hook_custom_status() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status(
- "herdr:codex".into(),
- "codex".into(),
- AgentState::Working,
- None,
- Some("planning".into()),
- None,
- );
-
- terminal.set_detected_state_with_visible_blocker(
- Some(Agent::Codex),
- AgentState::Blocked,
- true,
- false,
- false,
- );
-
- assert_eq!(terminal.state, AgentState::Blocked);
- assert_eq!(terminal.effective_custom_status(), None);
- }
-
- #[test]
fn fallback_idle_does_not_override_hook_working() {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:claude".into(),
"claude".into(),
AgentState::Working,
None,
- Some("thinking".into()),
None,
None,
now,
@@ -2950,10 +2889,6 @@ mod tests {
assert_eq!(terminal.fallback_state, AgentState::Idle);
assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("thinking")
- );
}
#[test]
@@ -2961,12 +2896,11 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:opencode".into(),
"opencode".into(),
AgentState::Working,
None,
- Some("thinking".into()),
None,
None,
now,
@@ -2983,10 +2917,6 @@ mod tests {
assert_eq!(terminal.fallback_state, AgentState::Working);
assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("thinking")
- );
}
#[test]
@@ -2994,14 +2924,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:claude".into(),
"claude".into(),
AgentState::Idle,
None,
None,
None,
- None,
now,
);
@@ -3025,14 +2954,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:hermes".into(),
"hermes".into(),
AgentState::Idle,
None,
None,
None,
- None,
now,
);
@@ -3056,14 +2984,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:kilo".into(),
"kilo".into(),
AgentState::Idle,
None,
None,
None,
- None,
now,
);
@@ -3096,14 +3023,13 @@ mod tests {
now,
);
- let change = terminal.set_hook_authority_with_custom_status_at(
+ let change = terminal.set_hook_authority_at(
"herdr:claude".into(),
"claude".into(),
AgentState::Idle,
None,
None,
None,
- None,
now + Duration::from_millis(100),
);
@@ -3131,12 +3057,11 @@ mod tests {
false,
now,
);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:codex".into(),
"codex".into(),
AgentState::Blocked,
None,
- Some("permission".into()),
None,
None,
now + Duration::from_millis(1201),
@@ -3156,10 +3081,6 @@ mod tests {
assert_eq!(terminal.fallback_state, AgentState::Working);
assert_eq!(terminal.state, AgentState::Blocked);
- assert_eq!(
- terminal.effective_custom_status().as_deref(),
- Some("permission")
- );
assert!(change.effective_state_change.is_none());
}
@@ -3271,14 +3192,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
None,
- None,
now,
);
@@ -3379,13 +3299,12 @@ mod tests {
false,
observed,
);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:claude".into(),
"claude".into(),
AgentState::Working,
None,
None,
- None,
Some(1),
observed + Duration::from_secs(1),
);
@@ -3416,22 +3335,20 @@ mod tests {
false,
observed,
);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:codex".into(),
"codex".into(),
AgentState::Working,
None,
None,
- None,
Some(1),
observed,
);
- terminal.set_hook_authority_with_custom_status_at(
+ terminal.set_hook_authority_at(
"herdr:codex".into(),
"codex".into(),
AgentState::Working,
None,
- Some("new turn".into()),
None,
Some(2),
observed + Duration::from_secs(1),
@@ -3448,7 +3365,7 @@ mod tests {
);
let authority = terminal.hook_authority.as_ref().expect("hook authority");
- assert_eq!(authority.custom_status.as_deref(), Some("new turn"));
+ assert_eq!(authority.reported_at, observed + Duration::from_secs(1));
assert_eq!(terminal.state, AgentState::Working);
assert_eq!(terminal.effective_agent_label(), Some("codex"));
}
@@ -3530,7 +3447,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
)
@@ -3560,7 +3476,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(20),
);
@@ -3570,7 +3485,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(new_session_path),
Some(19),
);
@@ -3595,7 +3509,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path),
Some(20),
);
@@ -3607,7 +3520,6 @@ mod tests {
AgentState::Working,
None,
None,
- None,
Some(21),
)
.expect("accepted report");
@@ -3638,7 +3550,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
Some(20),
)
@@ -4019,12 +3930,11 @@ mod tests {
let mut terminal = test_terminal();
let now = std::time::Instant::now();
terminal
- .set_hook_authority_with_custom_status_at(
+ .set_hook_authority_at(
"herdr:opencode".into(),
"opencode".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("opencode-session"),
Some(20),
now + Duration::from_millis(1),
@@ -4066,7 +3976,6 @@ mod tests {
"opencode".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("opencode-session"),
Some(22),
);
@@ -4078,7 +3987,6 @@ mod tests {
"opencode".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("opencode-new-session"),
Some(23),
);
@@ -4102,7 +4010,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"),
Some(21),
);
@@ -4152,7 +4059,6 @@ mod tests {
"opencode".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("opencode-session"),
Some(20),
)
@@ -4164,7 +4070,6 @@ mod tests {
"opencode".into(),
AgentState::Blocked,
Some("needs approval".into()),
- None,
crate::agent_resume::AgentSessionRef::id("nested-session"),
Some(21),
)
@@ -4224,7 +4129,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path),
Some(20),
);
@@ -4246,7 +4150,6 @@ mod tests {
"pi".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::path(session_path),
Some(20),
);
@@ -4419,7 +4322,6 @@ mod tests {
"claude".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("claude-session"),
Some(20),
);
@@ -4448,7 +4350,6 @@ mod tests {
"hermes".into(),
AgentState::Working,
None,
- None,
crate::agent_resume::AgentSessionRef::id("hermes-session"),
Some(20),
);
diff --git a/src/terminal/title.rs b/src/terminal/title.rs
new file mode 100644
index 00000000..32ba19c4
--- /dev/null
+++ b/src/terminal/title.rs
@@ -0,0 +1,64 @@
+const CLAUDE_ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽";
+
+pub(crate) fn stripped_terminal_title(title: &str) -> Option<String> {
+ let title = title.trim();
+ if title.is_empty() {
+ return None;
+ }
+
+ let mut chars = title.char_indices();
+ let (_, first) = chars.next()?;
+ let after_first = &title[first.len_utf8()..];
+ let recognized =
+ matches!(first, '\u{2800}'..='\u{28ff}') || CLAUDE_ACTIVITY_GLYPHS.contains(first);
+ let stripped = if recognized
+ && (after_first.is_empty() || after_first.chars().next().is_some_and(char::is_whitespace))
+ {
+ after_first.trim()
+ } else {
+ title
+ };
+
+ (!stripped.is_empty()).then(|| stripped.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::stripped_terminal_title;
+
+ #[test]
+ fn strips_one_recognized_leading_activity_glyph() {
+ for title in ["⠋ task", "✳ task", " ⠙ task ", "✢ task", "✻ task"] {
+ assert_eq!(stripped_terminal_title(title).as_deref(), Some("task"));
+ }
+ assert_eq!(
+ stripped_terminal_title("⠋ ⠙ task").as_deref(),
+ Some("⠙ task")
+ );
+ }
+
+ #[test]
+ fn preserves_unrecognized_or_unbounded_symbols() {
+ for (title, expected) in [
+ ("★task", "★task"),
+ ("★ production", "★ production"),
+ ("✨ task", "✨ task"),
+ ("☼ status", "☼ status"),
+ ("@ task", "@ task"),
+ ("task ⠋ detail", "task ⠋ detail"),
+ ("[prod] task", "[prod] task"),
+ ] {
+ assert_eq!(stripped_terminal_title(title).as_deref(), Some(expected));
+ }
+ }
+
+ #[test]
+ fn preserves_unicode_text_and_elides_empty_results() {
+ assert_eq!(
+ stripped_terminal_title(" ⠋ 修复🙂标题 ").as_deref(),
+ Some("修复🙂标题")
+ );
+ assert_eq!(stripped_terminal_title(" "), None);
+ assert_eq!(stripped_terminal_title("⠋ "), None);
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index dec8d7a9..76e1caa2 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -69,13 +69,14 @@ pub(crate) use self::{
SETTINGS_POPUP_WIDTH,
},
sidebar::{
- agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics,
- agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections,
- collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
- expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect,
- workspace_drop_indicator_row, workspace_list_entries, workspace_list_entries_expanded,
- workspace_list_rect, workspace_list_scroll_metrics, workspace_list_scrollbar_rect,
- workspace_parent_group_state, WorkspaceListEntry,
+ agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries,
+ agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect,
+ agent_panel_toggle_rect, collapsed_sidebar_sections, collapsed_sidebar_toggle_rect,
+ compute_workspace_card_areas, expanded_sidebar_sections, expanded_sidebar_toggle_rect,
+ normalized_workspace_scroll, sidebar_section_divider_rect, workspace_drop_indicator_row,
+ workspace_list_entries, workspace_list_entries_expanded, workspace_list_rect,
+ workspace_list_scroll_metrics, workspace_list_scrollbar_rect, workspace_parent_group_state,
+ WorkspaceListEntry,
},
};
pub(crate) use self::{
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index f1aae316..d1e6ac61 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -731,10 +731,6 @@ fn mobile_agent_detail(entry: &AgentPanelEntry) -> String {
if let Some(agent_label) = entry.agent_label.as_deref() {
parts.push(agent_label.to_string());
}
- if let Some(custom_status) = entry.custom_status.as_deref() {
- parts.push(custom_status.to_string());
- }
-
format!(" {}", parts.join(" · "))
}
@@ -1140,12 +1136,16 @@ mod tests {
pane_id: PaneId::from_raw(1),
primary_label: "herdr".into(),
primary_tab_label: primary_tab_label.map(str::to_string),
+ pane_label: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
agent_label: agent_label.map(str::to_string),
+ agent: agent_label.and_then(crate::detect::parse_agent_label),
state: AgentState::Idle,
seen: true,
last_agent_state_change_seq: None,
- custom_status: None,
state_labels: std::collections::HashMap::new(),
+ tokens: std::collections::HashMap::new(),
}
}
diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs
index 8bfe6531..0ad336f6 100644
--- a/src/ui/navigator.rs
+++ b/src/ui/navigator.rs
@@ -427,9 +427,6 @@ fn pane_detail(
} else {
parts.push("shell".to_string());
}
- if let Some(status) = terminal.effective_custom_status() {
- parts.push(status.to_string());
- }
}
}
parts.join(" · ")
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index ca23af0c..18d13b26 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -1,3 +1,5 @@
+mod tokens;
+
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
@@ -6,6 +8,7 @@ use ratatui::{
Frame,
};
+use self::tokens::{ResolvedToken, SpaceTokenContext};
use super::scrollbar::{render_scrollbar, should_show_scrollbar};
use super::status::{agent_icon, state_dot, state_label, state_label_color};
use super::text::{display_width, display_width_u16, truncate_end};
@@ -23,12 +26,16 @@ pub(crate) struct AgentPanelEntry {
pub pane_id: crate::layout::PaneId,
pub primary_label: String,
pub primary_tab_label: Option<String>,
+ pub pane_label: Option<String>,
+ pub terminal_title: Option<String>,
+ pub terminal_title_stripped: Option<String>,
pub agent_label: Option<String>,
+ pub agent: Option<crate::detect::Agent>,
pub state: AgentState,
pub seen: bool,
pub last_agent_state_change_seq: Option<u64>,
- pub custom_status: Option<String>,
pub state_labels: std::collections::HashMap<String, String>,
+ pub tokens: std::collections::HashMap<String, String>,
}
fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
@@ -131,12 +138,16 @@ fn agent_panel_entries_with_runtimes(
pane_id: detail.pane_id,
primary_label: workspace_label.clone(),
primary_tab_label: multi_tab.then_some(detail.tab_label),
+ pane_label: detail.pane_label,
+ terminal_title: detail.terminal_title,
+ terminal_title_stripped: detail.terminal_title_stripped,
agent_label: Some(detail.agent_label),
+ agent: detail.agent,
state: detail.state,
seen: detail.seen,
last_agent_state_change_seq: detail.last_agent_state_change_seq,
- custom_status: detail.custom_status,
state_labels: detail.state_labels,
+ tokens: detail.tokens,
})
})
.collect();
@@ -163,56 +174,48 @@ pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static
}
}
-fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
- let Some(tab_label) = entry.primary_tab_label.as_deref() else {
- return truncate_end(&entry.primary_label, max_width);
+fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indented: bool) -> u16 {
+ let (state, seen) = ws.aggregate_state(&app.terminals);
+ let label = if indented {
+ grouped_child_display_label(
+ &ws.display_name(),
+ ws.branch().as_deref(),
+ ws.custom_name.is_some(),
+ )
+ } else {
+ ws.display_name()
};
-
- let separator = " · ";
- let separator_width = display_width(separator);
- if max_width <= separator_width + 2 {
- return truncate_end(
- &format!("{}{}{}", entry.primary_label, separator, tab_label),
- max_width,
- );
- }
-
- let available = max_width.saturating_sub(separator_width);
- let min_tab = 4.min(available.saturating_sub(1)).max(1);
- let preferred_workspace = ((available * 2) / 3).max(1);
- let mut workspace_budget = preferred_workspace
- .min(available.saturating_sub(min_tab))
- .max(1);
- let mut tab_budget = available.saturating_sub(workspace_budget);
-
- let workspace_len = display_width(&entry.primary_label);
- let tab_len = display_width(tab_label);
-
- if workspace_len < workspace_budget {
- let spare = workspace_budget - workspace_len;
- workspace_budget = workspace_len;
- tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
- }
- if tab_len < tab_budget {
- let spare = tab_budget - tab_len;
- tab_budget = tab_len;
- workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
- }
-
- format!(
- "{}{}{}",
- truncate_end(&entry.primary_label, workspace_budget),
- separator,
- truncate_end(tab_label, tab_budget)
+ let token_values = ws.metadata_tokens.values();
+ tokens::space_rows(
+ &app.sidebar_spaces,
+ SpaceTokenContext {
+ workspace: &label,
+ branch: ws.branch().as_deref(),
+ state_text: state_label(state, seen),
+ ahead_behind: ws.git_ahead_behind(),
+ tokens: &token_values,
+ suppress_git_details: indented,
+ },
)
+ .len()
+ .max(1)
+ .min(u16::MAX as usize) as u16
}
-fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
- if ws.branch().is_some() {
- 2
- } else {
- 1
- }
+fn workspace_row_height_in_body(
+ app: &AppState,
+ workspace: &crate::workspace::Workspace,
+ indented: bool,
+ body_height: u16,
+) -> u16 {
+ workspace_row_height(app, workspace, indented).min(body_height)
+}
+
+fn workspace_entry_gap(entries: &[WorkspaceListEntry], entry_idx: usize, indented: bool) -> u16 {
+ u16::from(
+ entry_idx + 1 < entries.len()
+ && !(indented && next_entry_is_indented_workspace(entries, entry_idx)),
+ )
}
fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 {
@@ -294,11 +297,10 @@ pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested:
return requested;
}
- let entry_count = workspace_list_entries(app).len();
- if entry_count == 0 {
+ if workspace_list_entries(app).is_empty() {
0
} else {
- requested.min(entry_count.saturating_sub(1))
+ requested.min(workspace_list_bottom_start(app, ws_area))
}
}
@@ -440,47 +442,62 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us
let mut visible = 0usize;
let entries = workspace_list_entries(app);
for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) {
- let needed = match entry {
+ let (row_height, gap) = match entry {
WorkspaceListEntry::Workspace { ws_idx, indented } => {
let Some(ws) = app.workspaces.get(*ws_idx) else {
continue;
};
- let row_height = if *indented {
- 1
- } else {
- workspace_row_height(ws)
- };
- let gap = u16::from(
- !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)),
- );
- row_height.saturating_add(gap)
+ (
+ workspace_row_height_in_body(app, ws, *indented, body.height),
+ workspace_entry_gap(&entries, entry_idx, *indented),
+ )
}
};
- if used_rows.saturating_add(needed) > body.height {
+ if used_rows.saturating_add(row_height) > body.height {
break;
}
- used_rows = used_rows.saturating_add(needed);
+ used_rows = used_rows.saturating_add(row_height);
visible += 1;
+ if gap > 0 && used_rows < body.height {
+ used_rows = used_rows.saturating_add(1);
+ }
}
visible
}
+fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize {
+ let body = workspace_list_body_rect(area, false);
+ let entries = workspace_list_entries(app);
+ let mut used_rows = 0u16;
+ let mut start = entries.len();
+ for (entry_idx, entry) in entries.iter().enumerate().rev() {
+ let WorkspaceListEntry::Workspace { ws_idx, indented } = entry;
+ let Some(workspace) = app.workspaces.get(*ws_idx) else {
+ continue;
+ };
+ let gap = workspace_entry_gap(&entries, entry_idx, *indented);
+ let needed = workspace_row_height_in_body(app, workspace, *indented, body.height)
+ .saturating_add(gap);
+ if used_rows.saturating_add(needed) > body.height {
+ break;
+ }
+ used_rows = used_rows.saturating_add(needed);
+ start = entry_idx;
+ }
+ start.min(entries.len().saturating_sub(1))
+}
+
pub(crate) fn workspace_list_scroll_metrics(
app: &AppState,
area: Rect,
) -> crate::pane::ScrollMetrics {
- let entries = workspace_list_entries(app);
- let total_rows = entries.len();
- let scroll = app.workspace_scroll.min(total_rows.saturating_sub(1));
+ let max_scroll = workspace_list_bottom_start(app, area);
+ let scroll = app.workspace_scroll.min(max_scroll);
let viewport_rows = workspace_list_visible_count(app, area, scroll);
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- let offset_from_bottom = total_rows
- .saturating_sub(scroll)
- .saturating_sub(viewport_rows);
crate::pane::ScrollMetrics {
- offset_from_bottom,
- max_offset_from_bottom,
+ offset_from_bottom: max_scroll.saturating_sub(scroll),
+ max_offset_from_bottom: max_scroll,
viewport_rows,
}
}
@@ -507,16 +524,41 @@ pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
Rect::new(area.x, body_y, body_width, body_height)
}
-fn agent_panel_visible_count(area: Rect) -> usize {
+fn resolved_agent_rows(app: &AppState, entry: &AgentPanelEntry) -> Vec<Vec<ResolvedToken>> {
+ let label = entry
+ .state_labels
+ .get(agent_panel_status_key(entry.state, entry.seen))
+ .map(String::as_str)
+ .unwrap_or_else(|| state_label(entry.state, entry.seen));
+ tokens::agent_rows(&app.sidebar_agents, entry, label)
+}
+
+pub(crate) fn agent_entry_height_in_body(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ body_height: u16,
+) -> u16 {
+ (resolved_agent_rows(app, entry)
+ .len()
+ .max(1)
+ .min(u16::MAX as usize) as u16)
+ .min(body_height)
+}
+
+fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize {
let body = agent_panel_body_rect(area, false);
- if body.width == 0 || body.height < 2 {
+ if body.width == 0 || body.height == 0 {
return 0;
}
let mut used_rows = 0u16;
let mut visible = 0usize;
- while used_rows.saturating_add(2) <= body.height {
- used_rows = used_rows.saturating_add(2);
+ for entry in agent_panel_entries(app).iter().skip(scroll) {
+ let height = agent_entry_height_in_body(app, entry, body.height);
+ if used_rows.saturating_add(height) > body.height {
+ break;
+ }
+ used_rows = used_rows.saturating_add(height);
visible += 1;
if used_rows < body.height {
used_rows = used_rows.saturating_add(1);
@@ -525,17 +567,52 @@ fn agent_panel_visible_count(area: Rect) -> usize {
visible
}
+fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize {
+ let body = agent_panel_body_rect(area, false);
+ let entries = agent_panel_entries(app);
+ let mut used_rows = 0u16;
+ let mut start = entries.len();
+ for (index, entry) in entries.iter().enumerate().rev() {
+ let gap = u16::from(index + 1 < entries.len());
+ let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap);
+ if used_rows.saturating_add(needed) > body.height {
+ break;
+ }
+ used_rows = used_rows.saturating_add(needed);
+ start = index;
+ }
+ start.min(entries.len().saturating_sub(1))
+}
+
+pub(crate) fn agent_panel_scroll_for_target(
+ app: &AppState,
+ area: Rect,
+ current_scroll: usize,
+ target: usize,
+) -> usize {
+ let max_scroll = agent_panel_bottom_start(app, area);
+ if target < current_scroll {
+ return target.min(max_scroll);
+ }
+ let mut scroll = current_scroll.min(max_scroll);
+ while scroll < target {
+ let visible = agent_panel_visible_count_from(app, area, scroll);
+ if visible > 0 && target < scroll.saturating_add(visible) {
+ break;
+ }
+ scroll += 1;
+ }
+ scroll.min(max_scroll)
+}
+
pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
- let viewport_rows = agent_panel_visible_count(area);
- let total_rows = agent_panel_entries(app).len();
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- let offset_from_bottom = total_rows
- .saturating_sub(app.agent_panel_scroll)
- .saturating_sub(viewport_rows);
+ let max_scroll = agent_panel_bottom_start(app, area);
+ let scroll = app.agent_panel_scroll.min(max_scroll);
+ let viewport_rows = agent_panel_visible_count_from(app, area, scroll);
crate::pane::ScrollMetrics {
- offset_from_bottom,
- max_offset_from_bottom,
+ offset_from_bottom: max_scroll.saturating_sub(scroll),
+ max_offset_from_bottom: max_scroll,
viewport_rows,
}
}
@@ -579,15 +656,9 @@ pub(crate) fn compute_workspace_list_areas(
let Some(ws) = app.workspaces.get(*ws_idx) else {
continue;
};
- let row_height = if *indented {
- 1
- } else {
- workspace_row_height(ws)
- };
- let gap = u16::from(
- !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)),
- );
- if row_y.saturating_add(row_height).saturating_add(gap) > body_bottom {
+ let row_height = workspace_row_height_in_body(app, ws, *indented, body.height);
+ let gap = workspace_entry_gap(&entries, entry_idx, *indented);
+ if row_y.saturating_add(row_height) > body_bottom {
break;
}
cards.push(crate::app::state::WorkspaceCardArea {
@@ -595,7 +666,10 @@ pub(crate) fn compute_workspace_list_areas(
rect: Rect::new(body.x, row_y, body.width, row_height),
indented: *indented,
});
- row_y = row_y.saturating_add(row_height + gap);
+ row_y = row_y.saturating_add(row_height);
+ if gap > 0 && row_y < body_bottom {
+ row_y = row_y.saturating_add(1);
+ }
}
}
}
@@ -797,6 +871,179 @@ pub(super) fn render_sidebar(
render_sidebar_toggle(app, frame, area, false, p);
}
+fn resolved_token_spans(
+ resolved: &[ResolvedToken],
+ state_icon: (&str, Style),
+ state_text_style: Style,
+ workspace_style: Style,
+ secondary_style: Style,
+ custom_style: Style,
+ p: &Palette,
+ max_width: usize,
+) -> Vec<Span<'static>> {
+ let fixed_widths = resolved
+ .iter()
+ .map(|token| match token {
+ ResolvedToken::StateIcon => display_width(state_icon.0),
+ ResolvedToken::GitStatus { ahead, behind } => {
+ usize::from(*ahead > 0) * display_width(&format!("↑{ahead}"))
+ + usize::from(*behind > 0) * display_width(&format!("↓{behind}"))
+ + usize::from(*ahead > 0 && *behind > 0)
+ }
+ _ => 0,
+ })
+ .collect::<Vec<_>>();
+ let flexible_widths = resolved
+ .iter()
+ .map(|token| match token {
+ ResolvedToken::StateText(text)
+ | ResolvedToken::Workspace(text)
+ | ResolvedToken::Tab(text)
+ | ResolvedToken::Pane(text)
+ | ResolvedToken::Agent(text)
+ | ResolvedToken::TerminalTitle(text)
+ | ResolvedToken::Branch(text)
+ | ResolvedToken::Custom(text) => display_width(text),
+ _ => 0,
+ })
+ .collect::<Vec<_>>();
+ let minimum_width = |active: &[bool]| {
+ let indices = active
+ .iter()
+ .enumerate()
+ .filter_map(|(index, active)| active.then_some(index))
+ .collect::<Vec<_>>();
+ let content = indices
+ .iter()
+ .map(|index| fixed_widths[*index] + usize::from(flexible_widths[*index] > 0))
+ .sum::<usize>();
+ let separators = indices
+ .windows(2)
+ .map(|pair| display_width(tokens::separator(&resolved[pair[0]], &resolved[pair[1]])))
+ .sum::<usize>();
+ content + separators
+ };
+ let mut active = resolved.iter().map(|_| true).collect::<Vec<_>>();
+ if minimum_width(&active) > max_width {
+ for (index, width) in flexible_widths.iter().enumerate() {
+ if *width > 0 {
+ active[index] = false;
+ }
+ }
+ for index in (0..resolved.len()).rev() {
+ if flexible_widths[index] == 0 {
+ continue;
+ }
+ active[index] = true;
+ if minimum_width(&active) > max_width {
+ active[index] = false;
+ }
+ }
+ }
+ let visible_indices = active
+ .iter()
+ .enumerate()
+ .filter_map(|(index, active)| active.then_some(index))
+ .collect::<Vec<_>>();
+ let separator_width = visible_indices
+ .windows(2)
+ .map(|pair| display_width(tokens::separator(&resolved[pair[0]], &resolved[pair[1]])))
+ .sum::<usize>();
+ let fixed_width = visible_indices
+ .iter()
+ .map(|index| fixed_widths[*index])
+ .sum::<usize>();
+ let mut budgets = flexible_widths
+ .iter()
+ .enumerate()
+ .map(|(index, width)| usize::from(active[index] && *width > 0))
+ .collect::<Vec<_>>();
+ let minimum = budgets.iter().sum::<usize>();
+ let mut remaining = max_width
+ .saturating_sub(separator_width + fixed_width)
+ .saturating_sub(minimum);
+ while remaining > 0 {
+ let mut grew = false;
+ for (budget, width) in budgets.iter_mut().zip(&flexible_widths) {
+ if *budget > 0 && *budget < *width {
+ *budget += 1;
+ remaining -= 1;
+ grew = true;
+ if remaining == 0 {
+ break;
+ }
+ }
+ }
+ if !grew {
+ break;
+ }
+ }
+ let mut spans = Vec::new();
+ for (position, index) in visible_indices.iter().copied().enumerate() {
+ let token = &resolved[index];
+ if position > 0 {
+ let previous = &resolved[visible_indices[position - 1]];
+ spans.push(Span::styled(
+ tokens::separator(previous, token),
+ Style::default().fg(p.overlay0).add_modifier(Modifier::DIM),
+ ));
+ }
+ match token {
+ ResolvedToken::StateIcon => {
+ spans.push(Span::styled(state_icon.0.to_string(), state_icon.1));
+ }
+ ResolvedToken::StateText(text) => {
+ spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ state_text_style,
+ ));
+ }
+ ResolvedToken::Workspace(text) => {
+ spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ workspace_style,
+ ));
+ }
+ ResolvedToken::Tab(text) | ResolvedToken::Pane(text) | ResolvedToken::Agent(text) => {
+ spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ secondary_style,
+ ));
+ }
+ ResolvedToken::Branch(text) => {
+ spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ secondary_style,
+ ));
+ }
+ ResolvedToken::GitStatus { ahead, behind } => {
+ if *ahead > 0 {
+ spans.push(Span::styled(
+ format!("↑{ahead}"),
+ Style::default().fg(p.green),
+ ));
+ }
+ if *ahead > 0 && *behind > 0 {
+ spans.push(Span::raw(" "));
+ }
+ if *behind > 0 {
+ spans.push(Span::styled(
+ format!("↓{behind}"),
+ Style::default().fg(p.red),
+ ));
+ }
+ }
+ ResolvedToken::TerminalTitle(text) | ResolvedToken::Custom(text) => {
+ spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ custom_style,
+ ));
+ }
+ }
+ }
+ spans
+}
+
fn render_workspace_list(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -870,94 +1117,89 @@ fn render_workspace_list(
Style::default().fg(p.subtext0)
};
- let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
let label = ws.display_name_from(&app.terminals, terminal_runtimes);
- let mut line1 = Vec::new();
- let mut show_workspace_icon = true;
- if card.indented {
- line1.push(Span::styled(" ", Style::default()));
- } else if let Some((key, collapsed)) = workspace_parent_group_state(app, i) {
- let icon = if collapsed { "▸" } else { "▾" };
- let (state_icon, state_style) = if collapsed {
- let (state, seen) = space_aggregate_state(app, &key);
- state_dot(state, seen, p)
- } else {
- (icon, Style::default().fg(p.accent))
- };
- line1.push(Span::styled(icon, Style::default().fg(p.accent)));
- if collapsed {
- line1.push(Span::styled(" ", Style::default()));
- line1.push(Span::styled(state_icon, state_style));
- show_workspace_icon = false;
- }
- line1.push(Span::styled(" ", Style::default()));
+ let display_label = if card.indented {
+ grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some())
} else {
- line1.push(Span::styled(" ", Style::default()));
- }
- if show_workspace_icon {
- line1.push(Span::styled(icon, icon_style));
- line1.push(Span::styled(" ", Style::default()));
- }
- if card.indented {
- let display_label = grouped_child_display_label(
- &label,
- ws.branch().as_deref(),
- ws.custom_name.is_some(),
- );
- line1.push(Span::styled(display_label, name_style));
+ label
+ };
+ let parent_group = (!card.indented)
+ .then(|| workspace_parent_group_state(app, i))
+ .flatten();
+ let (display_state, display_seen) = parent_group
+ .as_ref()
+ .filter(|(_, collapsed)| *collapsed)
+ .map(|(key, _)| space_aggregate_state(app, key))
+ .unwrap_or((agg_state, agg_seen));
+ let state_icon = state_dot(display_state, display_seen, p);
+ let state_text_style = Style::default()
+ .fg(state_label_color(display_state, display_seen, p))
+ .add_modifier(Modifier::DIM);
+ let branch_style = Style::default().fg(if selected || is_active {
+ p.mauve
} else {
- line1.push(Span::styled(label, name_style));
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(line1)),
- Rect::new(card.rect.x, row_y, card.rect.width, 1),
+ p.overlay0
+ });
+ let token_values = ws.metadata_tokens.values();
+ let rows = tokens::space_rows(
+ &app.sidebar_spaces,
+ SpaceTokenContext {
+ workspace: &display_label,
+ branch: ws.branch().as_deref(),
+ state_text: state_label(display_state, display_seen),
+ ahead_behind: ws.git_ahead_behind(),
+ tokens: &token_values,
+ suppress_git_details: card.indented,
+ },
);
- if row_height > 1 && row_y + 1 < list_bottom {
- if let Some(branch) = ws.branch() {
- let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
- let mut parts = Vec::new();
- if ahead > 0 {
- parts.push((format!("↑{}", ahead), p.green));
- }
- if behind > 0 {
- parts.push((format!("↓{}", behind), p.red));
- }
- (!parts.is_empty()).then_some(parts)
- });
- let reserved = upstream_label
- .as_ref()
- .map(|parts| {
- parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
- })
- .unwrap_or(0);
- let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved);
- let branch_display = truncate_end(&branch, max_branch_len);
- let branch_color = if selected || is_active {
- p.mauve
+ for (row_index, resolved) in rows.iter().enumerate() {
+ if row_index as u16 >= row_height || row_y + row_index as u16 >= list_bottom {
+ break;
+ }
+ let mut spans = Vec::new();
+ if row_index == 0 {
+ if card.indented {
+ spans.push(Span::raw(" "));
+ } else if let Some((_, collapsed)) = parent_group.as_ref() {
+ spans.push(Span::styled(
+ if *collapsed { "▸" } else { "▾" },
+ Style::default().fg(p.accent),
+ ));
+ spans.push(Span::raw(" "));
} else {
- p.overlay0
- };
- let branch_indent = if card.indented { " " } else { " " };
- let mut spans = vec![
- Span::styled(branch_indent, Style::default()),
- Span::styled(branch_display, Style::default().fg(branch_color)),
- ];
- if let Some(parts) = upstream_label {
- spans.push(Span::styled(" ", Style::default()));
- for (idx, (label, color)) in parts.into_iter().enumerate() {
- if idx > 0 {
- spans.push(Span::styled(" ", Style::default()));
- }
- spans.push(Span::styled(label, Style::default().fg(color)));
- }
+ spans.push(Span::raw(" "));
}
- frame.render_widget(
- Paragraph::new(Line::from(spans)),
- Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
- );
+ } else {
+ spans.push(Span::raw(if card.indented { " " } else { " " }));
}
+ let prefix_width = if row_index == 0 {
+ if card.indented {
+ 3
+ } else if parent_group.is_some() {
+ 2
+ } else {
+ 1
+ }
+ } else if card.indented {
+ 5
+ } else {
+ 3
+ };
+ spans.extend(resolved_token_spans(
+ resolved,
+ state_icon,
+ state_text_style,
+ name_style,
+ branch_style,
+ branch_style,
+ p,
+ card.rect.width.saturating_sub(prefix_width) as usize,
+ ));
+ frame.render_widget(
+ Paragraph::new(Line::from(spans)),
+ Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1),
+ );
}
}
@@ -1050,27 +1292,19 @@ fn render_agent_detail(
let mut row_y = body.y;
let body_bottom = body.y + body.height;
for detail in details.iter().skip(app.agent_panel_scroll) {
- if row_y.saturating_add(1) >= body_bottom {
+ let label_color = state_label_color(detail.state, detail.seen, p);
+ let rows = resolved_agent_rows(app, detail);
+ let height = (rows.len().max(1) as u16).min(body.height);
+ if row_y.saturating_add(height) > body_bottom {
break;
}
- // Check if this agent entry corresponds to the active session
let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id);
-
- let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
- let label_color = state_label_color(detail.state, detail.seen, p);
- let label = detail
- .state_labels
- .get(agent_panel_status_key(detail.state, detail.seen))
- .map(String::as_str)
- .unwrap_or_else(|| state_label(detail.state, detail.seen));
-
let row_style = if is_active {
Style::default().bg(p.surface_dim)
} else {
Style::default()
};
-
let name_style = if is_active {
Style::default().fg(p.text).add_modifier(Modifier::BOLD)
} else {
@@ -1082,39 +1316,27 @@ fn render_agent_detail(
Style::default().fg(label_color).add_modifier(Modifier::DIM)
};
let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
-
- let primary_label =
- format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
- let name_line = Line::from(vec![
- Span::styled(" ", Style::default()),
- Span::styled(icon, icon_style),
- Span::styled(" ", Style::default()),
- Span::styled(primary_label, name_style),
- ]);
- frame.render_widget(
- Paragraph::new(name_line).style(row_style),
- Rect::new(body.x, row_y, body.width, 1),
- );
- row_y += 1;
-
- let mut status_spans = vec![
- Span::styled(" ", Style::default()),
- Span::styled(label, status_style),
- ];
- if let Some(agent_label) = &detail.agent_label {
- status_spans.push(Span::styled(" · ", agent_style));
- status_spans.push(Span::styled(agent_label, agent_style));
- }
- if let Some(custom_status) = &detail.custom_status {
- status_spans.push(Span::styled(" · ", agent_style));
- status_spans.push(Span::styled(custom_status.clone(), agent_style));
+ let state_icon = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+
+ for (row_index, resolved) in rows.iter().take(height as usize).enumerate() {
+ let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })];
+ spans.extend(resolved_token_spans(
+ resolved,
+ state_icon,
+ status_style,
+ name_style,
+ agent_style,
+ agent_style,
+ p,
+ body.width
+ .saturating_sub(if row_index == 0 { 1 } else { 3 }) as usize,
+ ));
+ frame.render_widget(
+ Paragraph::new(Line::from(spans)).style(row_style),
+ Rect::new(body.x, row_y + row_index as u16, body.width, 1),
+ );
}
- frame.render_widget(
- Paragraph::new(Line::from(status_spans)).style(row_style),
- Rect::new(body.x, row_y, body.width, 1),
- );
- row_y += 1;
-
+ row_y = row_y.saturating_add(height);
if row_y < body_bottom {
row_y += 1;
}
@@ -1177,6 +1399,208 @@ mod tests {
use crate::{detect::Agent, workspace::Workspace};
use ratatui::{backend::TestBackend, Terminal};
+ fn row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String {
+ (0..width)
+ .map(|x| buffer[(x, row)].symbol())
+ .collect::<String>()
+ .trim_end()
+ .to_string()
+ }
+
+ #[test]
+ fn default_agent_rows_remove_redundant_state_text() {
+ let mut app = crate::app::state::AppState::test_new();
+ let workspace = Workspace::test_new("one");
+ let pane_id = workspace.tabs[0].root_pane;
+ app.workspaces = vec![workspace];
+ app.ensure_test_terminals();
+ let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal_state = app.terminals.get_mut(&terminal_id).unwrap();
+ terminal_state.detected_agent = Some(Agent::Pi);
+ terminal_state.state = AgentState::Working;
+
+ let area = Rect::new(0, 0, 26, 20);
+ let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
+ terminal
+ .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+ let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+ let body = agent_panel_body_rect(agent_area, false);
+
+ let first = row_text(buffer, body.y, 25);
+ let second = row_text(buffer, body.y + 1, 25);
+ assert!(first.contains("one"));
+ assert_eq!(second, " pi");
+ assert!(!first.contains("working"));
+ assert!(!second.contains("working"));
+ }
+
+ #[test]
+ fn narrow_agent_rows_preserve_later_tab_tokens() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut workspace = Workspace::test_new("very-long-workspace-name");
+ let tab_idx = workspace.test_add_tab(Some("logs"));
+ let pane_id = workspace.tabs[tab_idx].root_pane;
+ app.workspaces = vec![workspace];
+ app.ensure_test_terminals();
+ let terminal_id = app.workspaces[0].tabs[tab_idx].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi);
+
+ let area = Rect::new(0, 0, 18, 20);
+ let mut terminal = Terminal::new(TestBackend::new(18, 20)).unwrap();
+ terminal
+ .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+ let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+ let body = agent_panel_body_rect(agent_area, false);
+ let first = row_text(buffer, body.y, 17);
+
+ assert!(first.contains("logs"), "rendered row: {first:?}");
+ assert!(first.contains('·'), "rendered row: {first:?}");
+ }
+
+ #[test]
+ fn stripped_terminal_title_renders_with_unicode_width_truncation() {
+ let mut app = crate::app::state::AppState::test_new();
+ let workspace = Workspace::test_new("one");
+ let pane_id = workspace.tabs[0].root_pane;
+ app.workspaces = vec![workspace];
+ app.ensure_test_terminals();
+ let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(Agent::Claude);
+ terminal.set_terminal_title(Some("⠋ 修复🙂标题很长".into()));
+ app.sidebar_agents.rows = vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]];
+
+ let area = Rect::new(0, 0, 10, 12);
+ let mut renderer = Terminal::new(TestBackend::new(10, 12)).unwrap();
+ renderer
+ .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+ let body = agent_panel_body_rect(agent_area, false);
+ let rendered = row_text(renderer.backend().buffer(), body.y, 9);
+
+ assert!(!rendered.contains('⠋'));
+ assert!(rendered.contains('修') && rendered.contains('复'));
+
+ let spans = resolved_token_spans(
+ &[ResolvedToken::TerminalTitle("修复🙂标题很长".into())],
+ ("", Style::default()),
+ Style::default(),
+ Style::default(),
+ Style::default(),
+ Style::default(),
+ &app.palette,
+ 8,
+ );
+ let text = spans
+ .iter()
+ .map(|span| span.content.as_ref())
+ .collect::<String>();
+ assert!(display_width(&text) <= 8, "resolved title: {text:?}");
+ }
+
+ #[test]
+ fn variable_agent_heights_pack_the_bottom_and_reveal_targets() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![
+ Workspace::test_new("one"),
+ Workspace::test_new("two"),
+ Workspace::test_new("three"),
+ ];
+ app.ensure_test_terminals();
+ for workspace in &app.workspaces {
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi);
+ }
+ let first_pane = app.workspaces[0].tabs[0].root_pane;
+ let first_terminal = app.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.terminals
+ .get_mut(&first_terminal)
+ .unwrap()
+ .metadata_tokens
+ .patch(
+ std::collections::HashMap::from([
+ ("a".into(), Some("a".into())),
+ ("b".into(), Some("b".into())),
+ ]),
+ None,
+ std::time::Instant::now(),
+ );
+ app.sidebar_agents.rows = vec![
+ vec![crate::config::AgentSidebarToken::Agent],
+ vec![crate::config::AgentSidebarToken::Custom("a".into())],
+ vec![crate::config::AgentSidebarToken::Custom("b".into())],
+ ];
+ let area = Rect::new(0, 0, 20, 6);
+
+ let metrics = agent_panel_scroll_metrics(&app, area);
+ assert_eq!(metrics.max_offset_from_bottom, 1);
+ assert_eq!(agent_panel_scroll_for_target(&app, area, 0, 2), 1);
+ }
+
+ #[test]
+ fn oversized_space_layout_is_clipped_to_the_section_body() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]; 6];
+ let area = Rect::new(0, 0, 20, 10);
+ let workspace_area = workspace_list_rect(area, app.sidebar_section_split);
+ let body = workspace_list_body_rect(workspace_area, false);
+
+ let metrics = workspace_list_scroll_metrics(&app, workspace_area);
+ let (cards, _) = compute_workspace_list_areas(&app, area);
+
+ assert_eq!(metrics.viewport_rows, 1);
+ assert_eq!(cards.len(), 1);
+ assert_eq!(cards[0].ws_idx, 0);
+ assert_eq!(cards[0].rect.height, body.height);
+ }
+
+ #[test]
+ fn oversized_agent_override_is_clipped_to_the_panel_body() {
+ let mut app = crate::app::state::AppState::test_new();
+ let workspace = Workspace::test_new("one");
+ let pane_id = workspace.tabs[0].root_pane;
+ app.workspaces = vec![workspace];
+ app.ensure_test_terminals();
+ let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude);
+ app.sidebar_agents.rows_by_agent.insert(
+ "claude".into(),
+ vec![vec![crate::config::AgentSidebarToken::Agent]; 6],
+ );
+ let panel = Rect::new(0, 0, 20, 5);
+
+ let metrics = agent_panel_scroll_metrics(&app, panel);
+
+ assert_eq!(metrics.viewport_rows, 1);
+ assert_eq!(metrics.max_offset_from_bottom, 0);
+ let entry = agent_panel_entries(&app).pop().unwrap();
+ assert_eq!(
+ agent_entry_height_in_body(&app, &entry, agent_panel_body_rect(panel, false).height),
+ agent_panel_body_rect(panel, false).height
+ );
+ }
+
#[test]
fn render_sidebar_toggle_draws_expanded_collapse_icon() {
let app = crate::app::state::AppState::test_new();
@@ -1488,27 +1912,6 @@ mod tests {
}
#[test]
- fn all_workspaces_primary_label_truncates_workspace_and_tab() {
- let entry = AgentPanelEntry {
- ws_idx: 0,
- tab_idx: 0,
- pane_id: crate::layout::PaneId::from_raw(1),
- primary_label: "agent-browser".into(),
- primary_tab_label: Some("test-escalation".into()),
- agent_label: Some("claude".into()),
- state: AgentState::Idle,
- seen: true,
- last_agent_state_change_seq: None,
- custom_status: None,
- state_labels: std::collections::HashMap::new(),
- };
-
- let label = format_agent_panel_primary_label(&entry, 18);
-
- assert_eq!(label, "agent-bro… · test…");
- }
-
- #[test]
fn expanded_sidebar_sections_handle_tiny_heights() {
let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
@@ -1638,7 +2041,7 @@ mod tests {
}
#[test]
- fn compact_space_group_scroll_offset_can_start_inside_group() {
+ fn compact_space_group_scroll_clamps_when_all_entries_fit() {
let mut app = AppState::test_new();
app.workspaces = vec![
workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
@@ -1651,8 +2054,9 @@ mod tests {
let (cards, headers) = compute_workspace_list_areas(&app, area);
assert!(headers.is_empty());
- assert_eq!(cards.len(), 1);
- assert_eq!(cards[0].ws_idx, 2);
+ assert_eq!(app.workspace_scroll, 0);
+ assert_eq!(cards.len(), 3);
+ assert_eq!(cards[2].ws_idx, 2);
}
#[test]
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
new file mode 100644
index 00000000..43c24d05
--- /dev/null
+++ b/src/ui/sidebar/tokens.rs
@@ -0,0 +1,285 @@
+use crate::config::{
+ AgentSidebarToken, AgentsSidebarConfig, SpaceSidebarToken, SpacesSidebarConfig,
+};
+
+use super::AgentPanelEntry;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum ResolvedToken {
+ StateIcon,
+ StateText(String),
+ Workspace(String),
+ Tab(String),
+ Pane(String),
+ Agent(String),
+ TerminalTitle(String),
+ Branch(String),
+ GitStatus { ahead: usize, behind: usize },
+ Custom(String),
+}
+
+pub(super) fn agent_rows(
+ config: &AgentsSidebarConfig,
+ entry: &AgentPanelEntry,
+ state_text: &str,
+) -> Vec<Vec<ResolvedToken>> {
+ config
+ .rows_for_agent(entry.agent)
+ .iter()
+ .filter_map(|row| {
+ let resolved = row
+ .iter()
+ .filter_map(|token| match token {
+ AgentSidebarToken::StateIcon => Some(ResolvedToken::StateIcon),
+ AgentSidebarToken::StateText => {
+ Some(ResolvedToken::StateText(state_text.to_string()))
+ }
+ AgentSidebarToken::Workspace => {
+ Some(ResolvedToken::Workspace(entry.primary_label.clone()))
+ }
+ AgentSidebarToken::Tab => {
+ entry.primary_tab_label.clone().map(ResolvedToken::Tab)
+ }
+ AgentSidebarToken::Pane => entry.pane_label.clone().map(ResolvedToken::Pane),
+ AgentSidebarToken::Agent => entry.agent_label.clone().map(ResolvedToken::Agent),
+ AgentSidebarToken::TerminalTitle => entry
+ .terminal_title
+ .clone()
+ .map(ResolvedToken::TerminalTitle),
+ AgentSidebarToken::TerminalTitleStripped => entry
+ .terminal_title_stripped
+ .clone()
+ .map(ResolvedToken::TerminalTitle),
+ AgentSidebarToken::Custom(name) => {
+ entry.tokens.get(name).cloned().map(ResolvedToken::Custom)
+ }
+ })
+ .collect::<Vec<_>>();
+ (!resolved.is_empty()).then_some(resolved)
+ })
+ .collect()
+}
+
+pub(super) struct SpaceTokenContext<'a> {
+ pub workspace: &'a str,
+ pub branch: Option<&'a str>,
+ pub state_text: &'a str,
+ pub ahead_behind: Option<(usize, usize)>,
+ pub tokens: &'a std::collections::HashMap<String, String>,
+ pub suppress_git_details: bool,
+}
+
+pub(super) fn space_rows(
+ config: &SpacesSidebarConfig,
+ context: SpaceTokenContext<'_>,
+) -> Vec<Vec<ResolvedToken>> {
+ config
+ .rows
+ .iter()
+ .filter_map(|row| {
+ let resolved = row
+ .iter()
+ .filter_map(|token| match token {
+ SpaceSidebarToken::StateIcon => Some(ResolvedToken::StateIcon),
+ SpaceSidebarToken::StateText => {
+ Some(ResolvedToken::StateText(context.state_text.to_string()))
+ }
+ SpaceSidebarToken::Workspace => {
+ Some(ResolvedToken::Workspace(context.workspace.to_string()))
+ }
+ SpaceSidebarToken::Branch if !context.suppress_git_details => context
+ .branch
+ .map(|branch| ResolvedToken::Branch(branch.to_string())),
+ SpaceSidebarToken::Branch => None,
+ SpaceSidebarToken::GitStatus if !context.suppress_git_details => context
+ .ahead_behind
+ .filter(|(ahead, behind)| *ahead > 0 || *behind > 0)
+ .map(|(ahead, behind)| ResolvedToken::GitStatus { ahead, behind }),
+ SpaceSidebarToken::GitStatus => None,
+ SpaceSidebarToken::Custom(name) => {
+ context.tokens.get(name).cloned().map(ResolvedToken::Custom)
+ }
+ })
+ .collect::<Vec<_>>();
+ (!resolved.is_empty()).then_some(resolved)
+ })
+ .collect()
+}
+
+pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str {
+ if matches!(previous, ResolvedToken::StateIcon)
+ || matches!(current, ResolvedToken::GitStatus { .. })
+ {
+ " "
+ } else {
+ " · "
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::detect::AgentState;
+
+ fn entry() -> AgentPanelEntry {
+ AgentPanelEntry {
+ ws_idx: 0,
+ tab_idx: 0,
+ pane_id: crate::layout::PaneId::from_raw(1),
+ primary_label: "repo".into(),
+ primary_tab_label: None,
+ pane_label: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_label: Some("pi".into()),
+ agent: Some(crate::detect::Agent::Pi),
+ state: AgentState::Working,
+ seen: true,
+ last_agent_state_change_seq: None,
+ state_labels: std::collections::HashMap::new(),
+ tokens: std::collections::HashMap::new(),
+ }
+ }
+
+ #[test]
+ fn missing_custom_tokens_elide_rows_and_separators() {
+ let entry = entry();
+ let config = AgentsSidebarConfig {
+ rows: vec![
+ vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Custom("missing".into()),
+ ],
+ vec![AgentSidebarToken::Custom("missing".into())],
+ vec![AgentSidebarToken::Agent],
+ ],
+ rows_by_agent: Default::default(),
+ };
+
+ let rows = agent_rows(&config, &entry, "working");
+
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0], vec![ResolvedToken::StateIcon]);
+ assert_eq!(rows[1], vec![ResolvedToken::Agent("pi".into())]);
+ }
+
+ #[test]
+ fn state_text_and_arbitrary_values_are_independent_tokens() {
+ let mut entry = entry();
+ entry
+ .tokens
+ .insert("summary".into(), "reviewing auth".into());
+ let config = AgentsSidebarConfig {
+ rows: vec![vec![
+ AgentSidebarToken::StateText,
+ AgentSidebarToken::Custom("summary".into()),
+ ]],
+ rows_by_agent: Default::default(),
+ };
+
+ assert_eq!(
+ agent_rows(&config, &entry, "deep in the mines"),
+ vec![vec![
+ ResolvedToken::StateText("deep in the mines".into()),
+ ResolvedToken::Custom("reviewing auth".into()),
+ ]]
+ );
+ }
+
+ #[test]
+ fn terminal_title_builtins_are_distinct_from_custom_tokens() {
+ let mut entry = entry();
+ entry.terminal_title = Some("⠋ raw title".into());
+ entry.terminal_title_stripped = Some("raw title".into());
+ entry
+ .tokens
+ .insert("terminal_title".into(), "custom title".into());
+ let config = AgentsSidebarConfig {
+ rows: vec![vec![
+ AgentSidebarToken::TerminalTitle,
+ AgentSidebarToken::TerminalTitleStripped,
+ AgentSidebarToken::Custom("terminal_title".into()),
+ ]],
+ rows_by_agent: Default::default(),
+ };
+
+ assert_eq!(
+ agent_rows(&config, &entry, "working"),
+ vec![vec![
+ ResolvedToken::TerminalTitle("⠋ raw title".into()),
+ ResolvedToken::TerminalTitle("raw title".into()),
+ ResolvedToken::Custom("custom title".into()),
+ ]]
+ );
+ }
+
+ #[test]
+ fn known_agent_override_replaces_default_rows() {
+ let mut config = AgentsSidebarConfig {
+ rows: vec![vec![AgentSidebarToken::Workspace]],
+ rows_by_agent: Default::default(),
+ };
+ config
+ .rows_by_agent
+ .insert("pi".into(), vec![vec![AgentSidebarToken::Agent]]);
+ let mut pi = entry();
+ pi.agent_label = Some("renamed pi".into());
+
+ assert_eq!(
+ agent_rows(&config, &pi, "working"),
+ vec![vec![ResolvedToken::Agent("renamed pi".into())]]
+ );
+
+ pi.agent = None;
+ assert_eq!(
+ agent_rows(&config, &pi, "working"),
+ vec![vec![ResolvedToken::Workspace("repo".into())]]
+ );
+ }
+
+ #[test]
+ fn grouped_children_suppress_all_builtin_git_details() {
+ let config = SpacesSidebarConfig::default();
+
+ assert_eq!(
+ space_rows(
+ &config,
+ SpaceTokenContext {
+ workspace: "feature",
+ branch: Some("worktree/feature"),
+ state_text: "idle",
+ ahead_behind: Some((2, 1)),
+ tokens: &std::collections::HashMap::new(),
+ suppress_git_details: true,
+ },
+ ),
+ vec![vec![
+ ResolvedToken::StateIcon,
+ ResolvedToken::Workspace("feature".into()),
+ ]]
+ );
+ }
+
+ #[test]
+ fn workspace_custom_token_can_replace_git_specific_details() {
+ let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]);
+ let config = SpacesSidebarConfig {
+ rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]],
+ };
+
+ assert_eq!(
+ space_rows(
+ &config,
+ SpaceTokenContext {
+ workspace: "repo",
+ branch: None,
+ state_text: "idle",
+ ahead_behind: None,
+ tokens: &tokens,
+ suppress_git_details: false,
+ },
+ ),
+ vec![vec![ResolvedToken::Custom("2 changes".into())]]
+ );
+ }
+}
diff --git a/src/workspace.rs b/src/workspace.rs
index f6060338..e90bd359 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -157,6 +157,8 @@ pub struct Workspace {
pub(crate) cached_git_space: Option<GitSpaceMetadata>,
/// Explicit Herdr-managed worktree grouping provenance.
pub worktree_space: Option<WorktreeSpaceMembership>,
+ pub(crate) metadata_tokens: crate::metadata_tokens::MetadataTokens,
+ pub(crate) metadata_token_sequences: HashMap<String, u64>,
/// Public pane numbers within this workspace. Closed pane numbers are not reused.
pub public_pane_numbers: HashMap<PaneId, usize>,
pub(crate) next_public_pane_number: usize,
@@ -216,6 +218,8 @@ impl Workspace {
cached_git_ahead_behind: None,
cached_git_space: git_space_metadata(&identity_cwd),
worktree_space: None,
+ metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
+ metadata_token_sequences: HashMap::new(),
public_pane_numbers,
next_public_pane_number: 2,
next_public_tab_number: 2,
@@ -397,6 +401,8 @@ impl Workspace {
cached_git_ahead_behind: None,
cached_git_space: None,
worktree_space: None,
+ metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
+ metadata_token_sequences: HashMap::new(),
public_pane_numbers,
next_public_pane_number: 2,
next_public_tab_number: 2,
@@ -1208,6 +1214,8 @@ impl Workspace {
cached_git_ahead_behind: None,
cached_git_space: None,
worktree_space: None,
+ metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
+ metadata_token_sequences: HashMap::new(),
public_pane_numbers,
next_public_pane_number: 2,
next_public_tab_number: 2,
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index 365decbf..a3eed860 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -12,14 +12,16 @@ pub struct PaneDetail {
pub tab_idx: usize,
pub tab_label: String,
pub label: String,
+ pub pane_label: Option<String>,
+ pub terminal_title: Option<String>,
+ pub terminal_title_stripped: Option<String>,
pub agent_label: String,
- #[allow(dead_code)]
pub agent: Option<Agent>,
pub state: AgentState,
pub seen: bool,
pub last_agent_state_change_seq: Option<u64>,
- pub custom_status: Option<String>,
pub state_labels: HashMap<String, String>,
+ pub tokens: HashMap<String, String>,
}
impl Tab {
@@ -57,13 +59,18 @@ impl Tab {
tab_idx,
tab_label: tab_label.to_string(),
label: agent_label.clone(),
+ pane_label: terminal
+ .effective_title()
+ .or_else(|| terminal.manual_label.clone()),
+ terminal_title: terminal.terminal_title.clone(),
+ terminal_title_stripped: terminal.terminal_title_stripped(),
agent_label,
agent: terminal.effective_known_agent(),
state: terminal.state,
seen: pane.seen,
last_agent_state_change_seq: terminal.last_agent_state_change_seq,
- custom_status: presentation.custom_status,
state_labels: presentation.state_labels,
+ tokens: terminal.metadata_tokens.values(),
})
})
.collect()
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index aea8188a..651a3aa5 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -425,6 +425,26 @@ fn workspace_list_and_create_round_trip() {
);
assert_eq!(fetched["result"]["workspace"]["workspace_id"], workspace_id);
+ let metadata = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_workspace_metadata","method":"workspace.report_metadata","params":{{"workspace_id":"{}","source":"user:test","tokens":{{"jj_status":"2 changes"}}}}}}"#,
+ workspace_id
+ ),
+ );
+ assert_eq!(metadata["result"]["type"], "ok");
+ let fetched = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_workspace_metadata_get","method":"workspace.get","params":{{"workspace_id":"{}"}}}}"#,
+ workspace_id
+ ),
+ );
+ assert_eq!(
+ fetched["result"]["workspace"]["tokens"]["jj_status"],
+ "2 changes"
+ );
+
let panes = send_request(
&socket_path,
r#"{"id":"req_6","method":"pane.list","params":{}}"#,
@@ -1545,7 +1565,7 @@ fn pane_report_agent_updates_effective_state() {
let metadata = send_request(
&socket_path,
&format!(
- r#"{{"id":"req_hook_metadata","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"Refactor auth","display_agent":"Pi auth","custom_status":"middleware","state_labels":{{"working":"deep in the mines"}}}}}}"#,
+ r#"{{"id":"req_hook_metadata","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"Refactor auth","display_agent":"Pi auth","state_labels":{{"working":"deep in the mines"}},"tokens":{{"summary":"reviewing auth","model":"opus"}}}}}}"#,
pane_id
),
);
@@ -1562,11 +1582,15 @@ fn pane_report_agent_updates_effective_state() {
assert_eq!(pane["result"]["pane"]["agent_status"], "working");
assert_eq!(pane["result"]["pane"]["title"], "Refactor auth");
assert_eq!(pane["result"]["pane"]["display_agent"], "Pi auth");
- assert_eq!(pane["result"]["pane"]["custom_status"], "middleware");
assert_eq!(
pane["result"]["pane"]["state_labels"]["working"],
"deep in the mines"
);
+ assert_eq!(
+ pane["result"]["pane"]["tokens"]["summary"],
+ "reviewing auth"
+ );
+ assert_eq!(pane["result"]["pane"]["tokens"]["model"], "opus");
let agent = send_request(
&socket_path,
@@ -1585,28 +1609,20 @@ fn pane_report_agent_updates_effective_state() {
);
assert_eq!(agent["result"]["agent"]["title"], "Refactor auth");
assert_eq!(agent["result"]["agent"]["display_agent"], "Pi auth");
- assert_eq!(agent["result"]["agent"]["custom_status"], "middleware");
assert_eq!(
agent["result"]["agent"]["state_labels"]["working"],
"deep in the mines"
);
-
- let invalid_metadata = send_request(
- &socket_path,
- &format!(
- r#"{{"id":"req_hook_metadata_invalid","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","custom_status":"x","clear_custom_status":true}}}}"#,
- pane_id
- ),
- );
assert_eq!(
- invalid_metadata["error"]["code"],
- "invalid_metadata_request"
+ agent["result"]["agent"]["tokens"]["summary"],
+ "reviewing auth"
);
+ assert_eq!(agent["result"]["agent"]["tokens"]["model"], "opus");
let blank_source_metadata = send_request(
&socket_path,
&format!(
- r#"{{"id":"req_hook_metadata_blank_source","method":"pane.report_metadata","params":{{"pane_id":"{}","source":" ","custom_status":"x"}}}}"#,
+ r#"{{"id":"req_hook_metadata_blank_source","method":"pane.report_metadata","params":{{"pane_id":"{}","source":" ","title":"x"}}}}"#,
pane_id
),
);
@@ -1630,7 +1646,7 @@ fn pane_report_agent_updates_effective_state() {
let blank_authority_source_metadata = send_request(
&socket_path,
&format!(
- r#"{{"id":"req_hook_metadata_blank_authority_source","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","applies_to_source":" ","custom_status":"x"}}}}"#,
+ r#"{{"id":"req_hook_metadata_blank_authority_source","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","applies_to_source":" ","title":"x"}}}}"#,
pane_id
),
);
@@ -2277,7 +2293,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
let metadata = send_request(
&socket_path,
&format!(
- r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","custom_status":"filtered out"}}}}"#,
+ r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"filtered out"}}}}"#,
pane_id
),
);
@@ -2303,7 +2319,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
let metadata = send_request(
&socket_path,
&format!(
- r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","custom_status":"short lived","ttl_ms":100}}}}"#,
+ r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"short lived","ttl_ms":100}}}}"#,
pane_id
),
);
@@ -2314,14 +2330,14 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
assert_eq!(set_event["data"]["pane_id"], pane_id);
assert_eq!(set_event["data"]["agent_status"], "working");
assert_eq!(set_event["data"]["agent"], "pi");
- assert_eq!(set_event["data"]["custom_status"], "short lived");
+ assert_eq!(set_event["data"]["title"], "short lived");
let expiry_event = reader.read_json_line(Duration::from_secs(3));
assert_eq!(expiry_event["event"], "pane.agent_status_changed");
assert_eq!(expiry_event["data"]["pane_id"], pane_id);
assert_eq!(expiry_event["data"]["agent_status"], "working");
assert_eq!(expiry_event["data"]["agent"], "pi");
- assert!(expiry_event["data"]["custom_status"].is_null());
+ assert!(expiry_event["data"]["title"].is_null());
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 10a00c41..78d4c123 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -898,6 +898,61 @@ fn pane_run_sends_one_send_input_request_with_enter_key() {
}
#[test]
+fn workspace_report_metadata_sends_token_patch() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("herdr.sock");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+
+ let server = thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ let mut reader = BufReader::new(stream.try_clone().unwrap());
+ reader.read_line(&mut line).unwrap();
+ stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ stream.write_all(b"\n").unwrap();
+ stream.flush().unwrap();
+ line
+ });
+
+ let run = run_cli(
+ &socket_path,
+ &[
+ "workspace",
+ "report-metadata",
+ "2",
+ "--source",
+ "user:jj",
+ "--token",
+ "jj_status=2 changes",
+ "--token",
+ "summary=clean",
+ "--clear-token",
+ "old",
+ "--ttl-ms",
+ "5000",
+ ],
+ );
+ assert!(
+ run.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ let request: serde_json::Value = serde_json::from_str(&server.join().unwrap()).unwrap();
+ assert_eq!(request["method"], "workspace.report_metadata");
+ assert_eq!(request["params"]["workspace_id"], "2");
+ assert_eq!(request["params"]["tokens"]["jj_status"], "2 changes");
+ assert_eq!(request["params"]["tokens"]["summary"], "clean");
+ assert!(request["params"]["tokens"]["old"].is_null());
+ assert_eq!(request["params"]["ttl_ms"], 5000);
+
+ cleanup_test_base(&base);
+}
+
+#[test]
fn pane_report_metadata_sends_presentation_request() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
@@ -931,10 +986,14 @@ fn pane_report_metadata_sends_presentation_request() {
"Refactor auth",
"--display-agent",
"Claude auth",
- "--custom-status",
- "middleware",
"--state-label",
"working=deep in the mines",
+ "--token",
+ "summary=reviewing auth",
+ "--token",
+ "model=opus",
+ "--clear-token",
+ "old",
"--ttl-ms",
"3600000",
],
@@ -954,11 +1013,13 @@ fn pane_report_metadata_sends_presentation_request() {
assert!(request["params"]["applies_to_source"].is_null());
assert_eq!(request["params"]["title"], "Refactor auth");
assert_eq!(request["params"]["display_agent"], "Claude auth");
- assert_eq!(request["params"]["custom_status"], "middleware");
assert_eq!(
request["params"]["state_labels"]["working"],
"deep in the mines"
);
+ assert_eq!(request["params"]["tokens"]["summary"], "reviewing auth");
+ assert_eq!(request["params"]["tokens"]["model"], "opus");
+ assert!(request["params"]["tokens"]["old"].is_null());
assert_eq!(request["params"]["ttl_ms"], 3_600_000);
cleanup_test_base(&base);
@@ -978,8 +1039,8 @@ fn pane_report_metadata_rejects_blank_source_before_socket_request() {
"1-1",
"--source",
" ",
- "--custom-status",
- "middleware",
+ "--token",
+ "summary=middleware",
],
);
@@ -1009,8 +1070,8 @@ fn pane_report_metadata_rejects_blank_applies_to_source_before_socket_request()
"user:claude-title",
"--applies-to-source",
" ",
- "--custom-status",
- "middleware",
+ "--token",
+ "summary=middleware",
],
);
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 9fe71790..0ee9aa2b 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -836,7 +836,9 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
- frame_contains_text(frame, "working")
+ ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+ .iter()
+ .any(|symbol| frame_contains_text(frame, symbol))
})
.expect("frame decoding should succeed");
assert!(
@@ -844,21 +846,23 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
"reattached client frame should expose persisted agent working status"
);
- // Transition to idle and verify API + client surfaces both observe it.
- pane_report_agent(&api_socket, &pane_id, "pi", "idle", "cross-area-test");
+ // Transition to blocked and verify API + client surfaces both observe it.
+ // The fake process remains visibly working, so blocked is the deterministic
+ // higher-priority semantic transition for this cross-area projection test.
+ pane_report_agent(&api_socket, &pane_id, "pi", "blocked", "cross-area-test");
assert!(
- wait_for_agent_status(&api_socket, &pane_id, "idle", Duration::from_secs(3)),
- "pane agent status should transition to idle"
+ wait_for_agent_status(&api_socket, &pane_id, "blocked", Duration::from_secs(3)),
+ "pane agent status should transition to blocked"
);
- let saw_idle_on_client =
+ let saw_blocked_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
- frame_contains_text(frame, "idle")
+ frame_contains_text(frame, "◉")
})
.expect("frame decoding should succeed");
assert!(
- saw_idle_on_client,
- "reattached client frame should show idle status after transition"
+ saw_blocked_on_client,
+ "reattached client frame should show blocked status after transition"
);
cleanup_spawned_herdr(server, base);