aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-20 00:54:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-20 00:55:36 +0300
commitd30ab1b54f6b6be95a0cb47ddc38da1d5e088700 (patch)
treeeb2002b408c856d9fcd7a573afe73b917f22cd97
parent3f80947678a9723434aa7fddfe23750b5393e24c (diff)
feat: add plugin-driven agent views and startup hooks
-rw-r--r--docs/next/CHANGELOG.md2
-rw-r--r--docs/next/api/herdr-api.schema.json366
-rw-r--r--docs/next/website/src/content/docs/plugins.mdx34
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx80
-rw-r--r--src/api/mod.rs4
-rw-r--r--src/api/schema.rs4
-rw-r--r--src/api/schema/agents.rs114
-rw-r--r--src/api/schema/plugins.rs9
-rw-r--r--src/api/schema/response.rs7
-rw-r--r--src/api/schema/tests.rs44
-rw-r--r--src/api/server.rs2
-rw-r--r--src/app/agent_view.rs573
-rw-r--r--src/app/api.rs5
-rw-r--r--src/app/api/agent_view.rs185
-rw-r--r--src/app/api/plugins/manifest.rs29
-rw-r--r--src/app/api/plugins/mod.rs96
-rw-r--r--src/app/api/plugins/runtime.rs35
-rw-r--r--src/app/api/worktrees.rs1
-rw-r--r--src/app/input/navigate.rs48
-rw-r--r--src/app/input/sidebar.rs49
-rw-r--r--src/app/input/terminal.rs1
-rw-r--r--src/app/mod.rs9
-rw-r--r--src/app/state.rs3
-rw-r--r--src/app/worktrees.rs1
-rw-r--r--src/cli/plugin.rs5
-rw-r--r--src/persist/plugin_registry.rs2
-rw-r--r--src/server/headless.rs2
-rw-r--r--src/ui.rs12
-rw-r--r--src/ui/mobile.rs102
-rw-r--r--src/ui/sidebar.rs75
-rw-r--r--src/ui/sidebar/tokens.rs1
-rw-r--r--src/workspace/aggregate.rs5
32 files changed, 1835 insertions, 70 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index f9805cda..64bd38f8 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -4,6 +4,8 @@
### Added
- Added a live-agent CLI facade with named `start`, atomic `prompt`, logical `send-keys`, and server-owned `wait` workflows. Agent startup targets an existing pane without changing topology, validates the requested interactive agent kind and strict agent name, and accepts native arguments after `--`.
+- Added transient declarative Agent view queries through `agent.view.set/clear`; filtered and sorted views now define sidebar, mobile, mouse, and agent-keybind navigation order.
+- Added one-shot plugin `[[startup]]` hooks for restoring plugin-owned state after server startup and live handoff.
- Added `ui.sidebar_start_collapsed` to launch Herdr with the sidebar collapsed. (#1463)
- Added macOS support for the `HERDR_AGENT=<agent>` foreground-process hint, allowing agents hidden behind host-visible wrappers such as `nono` to use the named agent's screen manifest. (#679)
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
index b066574f..dab07fb2 100644
--- a/docs/next/api/herdr-api.schema.json
+++ b/docs/next/api/herdr-api.schema.json
@@ -1354,6 +1354,283 @@
],
"type": "object"
},
+ "AgentViewBuiltinField": {
+ "enum": [
+ "status",
+ "workspace_id",
+ "tab_id",
+ "pane_id",
+ "agent",
+ "seen",
+ "state_change_seq"
+ ],
+ "type": "string"
+ },
+ "AgentViewBuiltinSortField": {
+ "enum": [
+ "workspace_order",
+ "tab_order",
+ "pane_order",
+ "attention",
+ "status",
+ "agent",
+ "seen",
+ "state_change_seq"
+ ],
+ "type": "string"
+ },
+ "AgentViewClearParams": {
+ "properties": {
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "AgentViewContext": {
+ "enum": [
+ "current_workspace_id",
+ "current_tab_id"
+ ],
+ "type": "string"
+ },
+ "AgentViewField": {
+ "anyOf": [
+ {
+ "$ref": "#/schemas/request/$defs/AgentViewBuiltinField"
+ },
+ {
+ "properties": {
+ "token": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "token"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "AgentViewFilter": {
+ "oneOf": [
+ {
+ "properties": {
+ "filters": {
+ "items": {
+ "$ref": "#/schemas/request/$defs/AgentViewFilter"
+ },
+ "type": "array"
+ },
+ "op": {
+ "const": "all",
+ "type": "string"
+ }
+ },
+ "required": [
+ "op",
+ "filters"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "filters": {
+ "items": {
+ "$ref": "#/schemas/request/$defs/AgentViewFilter"
+ },
+ "type": "array"
+ },
+ "op": {
+ "const": "any",
+ "type": "string"
+ }
+ },
+ "required": [
+ "op",
+ "filters"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "filter": {
+ "$ref": "#/schemas/request/$defs/AgentViewFilter"
+ },
+ "op": {
+ "const": "not",
+ "type": "string"
+ }
+ },
+ "required": [
+ "op",
+ "filter"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "field": {
+ "$ref": "#/schemas/request/$defs/AgentViewField"
+ },
+ "op": {
+ "const": "eq",
+ "type": "string"
+ },
+ "value": {
+ "$ref": "#/schemas/request/$defs/AgentViewValue"
+ }
+ },
+ "required": [
+ "op",
+ "field",
+ "value"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "field": {
+ "$ref": "#/schemas/request/$defs/AgentViewField"
+ },
+ "op": {
+ "const": "in",
+ "type": "string"
+ },
+ "values": {
+ "items": {
+ "$ref": "#/schemas/request/$defs/AgentViewValue"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "op",
+ "field",
+ "values"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "field": {
+ "$ref": "#/schemas/request/$defs/AgentViewField"
+ },
+ "op": {
+ "const": "exists",
+ "type": "string"
+ }
+ },
+ "required": [
+ "op",
+ "field"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "AgentViewSetParams": {
+ "properties": {
+ "filter": {
+ "anyOf": [
+ {
+ "$ref": "#/schemas/request/$defs/AgentViewFilter"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "label": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sort": {
+ "items": {
+ "$ref": "#/schemas/request/$defs/AgentViewSort"
+ },
+ "type": "array"
+ },
+ "source": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "source"
+ ],
+ "type": "object"
+ },
+ "AgentViewSort": {
+ "properties": {
+ "field": {
+ "$ref": "#/schemas/request/$defs/AgentViewSortField"
+ },
+ "order": {
+ "$ref": "#/schemas/request/$defs/AgentViewSortOrder",
+ "default": "asc"
+ }
+ },
+ "required": [
+ "field"
+ ],
+ "type": "object"
+ },
+ "AgentViewSortField": {
+ "anyOf": [
+ {
+ "$ref": "#/schemas/request/$defs/AgentViewBuiltinSortField"
+ },
+ {
+ "properties": {
+ "token": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "token"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "AgentViewSortOrder": {
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "type": "string"
+ },
+ "AgentViewValue": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "format": "uint64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ {
+ "properties": {
+ "context": {
+ "$ref": "#/schemas/request/$defs/AgentViewContext"
+ }
+ },
+ "required": [
+ "context"
+ ],
+ "type": "object"
+ }
+ ]
+ },
"AgentWaitParams": {
"properties": {
"target": {
@@ -4601,6 +4878,38 @@
{
"properties": {
"method": {
+ "const": "agent.view.set",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/schemas/request/$defs/AgentViewSetParams"
+ }
+ },
+ "required": [
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "method": {
+ "const": "agent.view.clear",
+ "type": "string"
+ },
+ "params": {
+ "$ref": "#/schemas/request/$defs/AgentViewClearParams"
+ }
+ },
+ "required": [
+ "method",
+ "params"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
+ "method": {
"const": "agent.focus",
"type": "string"
},
@@ -6632,6 +6941,12 @@
"kind": "local"
}
},
+ "startup": {
+ "items": {
+ "$ref": "#/schemas/success_response/$defs/PluginManifestStartup"
+ },
+ "type": "array"
+ },
"version": {
"type": "string"
},
@@ -7975,6 +8290,29 @@
],
"type": "object"
},
+ "PluginManifestStartup": {
+ "properties": {
+ "command": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "platforms": {
+ "items": {
+ "$ref": "#/schemas/success_response/$defs/PluginPlatform"
+ },
+ "type": [
+ "array",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "command"
+ ],
+ "type": "object"
+ },
"PluginPaneInfo": {
"properties": {
"entrypoint": {
@@ -8451,6 +8789,34 @@
},
{
"properties": {
+ "active": {
+ "type": "boolean"
+ },
+ "label": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "const": "agent_view",
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "active"
+ ],
+ "type": "object"
+ },
+ {
+ "properties": {
"pane": {
"$ref": "#/schemas/success_response/$defs/PaneInfo"
},
diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx
index 500ae5a3..c86dc69a 100644
--- a/docs/next/website/src/content/docs/plugins.mdx
+++ b/docs/next/website/src/content/docs/plugins.mdx
@@ -73,6 +73,9 @@ command = ["npm", "ci"]
command = ["npm", "run", "build"]
platforms = ["linux", "macos"]
+[[startup]]
+command = ["node", "dist/restore.js"]
+
[[actions]]
id = "apply"
title = "Apply layout"
@@ -109,9 +112,10 @@ dots. Each id type must be unique inside a plugin. Herdr qualifies action ids
as `plugin.id.action` when it needs a globally unique name.
Use `platforms = ["linux", "macos", "windows"]` to declare where the plugin
-can run. Build commands, actions, event hooks, panes, and link handlers can
-also declare their own `platforms`; item-level platforms override the top-level
-list. Local plugins without top-level `platforms` link with a warning.
+can run. Build commands, startup hooks, actions, event hooks, panes, and link
+handlers can also declare their own `platforms`; item-level platforms override
+the top-level list. Local plugins without top-level `platforms` link with a
+warning.
`command` values are argv arrays. Herdr does not run them through a shell, so
there is no shell expansion unless your command starts a shell itself. Put
@@ -222,6 +226,24 @@ plugin context or Herdr socket env. Plugin authors should document required
system tools such as `cargo`, `npm`, `bun`, or `lua`; Herdr reports build
failures but does not install missing toolchains.
+## Startup hooks
+
+`[[startup]]` commands run once for each enabled plugin after Herdr restores the
+session and its API socket is ready. They run again when a new server takes over
+during live handoff, but not when a client attaches, config reloads, or a plugin
+is linked or enabled. Herdr starts them asynchronously and records their
+completion in the normal plugin command log. A startup failure does not stop the
+server.
+
+Startup hooks are one-shot initialization commands, not supervised daemons. A
+hook should restore plugin-owned state, call any required Herdr APIs, and exit.
+For example, a plugin can save a declarative Agent view under
+`HERDR_PLUGIN_STATE_DIR`, then read and reapply that view from its startup hook.
+
+Startup hooks receive the normal runtime plugin environment and
+`HERDR_PLUGIN_EVENT=startup`. The install preview lists every startup command so
+users can review code that will run automatically.
+
## Commands and environment
Runtime commands run with the plugin directory as their working directory. Herdr
@@ -229,8 +251,10 @@ injects `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV=1`,
`HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`,
`HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_CONTEXT_JSON`, and any available
`HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID`. Action commands also
-receive `HERDR_PLUGIN_ACTION_ID`; event hooks receive `HERDR_PLUGIN_EVENT` and
-`HERDR_PLUGIN_EVENT_JSON`; pane commands receive `HERDR_PLUGIN_ENTRYPOINT_ID`.
+receive `HERDR_PLUGIN_ACTION_ID`; startup and event hooks receive
+`HERDR_PLUGIN_EVENT` (`startup` for startup hooks), event hooks additionally
+receive `HERDR_PLUGIN_EVENT_JSON`, and pane commands receive
+`HERDR_PLUGIN_ENTRYPOINT_ID`.
`HERDR_PLUGIN_ROOT` is the installed or linked plugin directory. Do not store
user credentials or durable state there, because GitHub-installed plugin roots
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 66072d3c..68045ecb 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -106,7 +106,7 @@ Raw socket method names use dot notation:
| 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.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
| Popup | `popup.close` |
| Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` |
-| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start` |
+| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start`, `agent.view.set`, `agent.view.clear` |
| Events | `events.subscribe`, `events.wait` |
| Integrations | `integration.install`, `integration.uninstall` |
| Plugins | `plugin.link`, `plugin.list`, `plugin.unlink`, `plugin.enable`, `plugin.disable`, `plugin.action.list`, `plugin.action.invoke`, `plugin.log.list`, `plugin.pane.open`, `plugin.pane.focus`, `plugin.pane.close` |
@@ -366,11 +366,87 @@ Use at most one of `workspace_id` or `cwd` for `worktree.list`, `worktree.create
Worktree commands also emit lifecycle events. `worktree.create` emits `workspace.created`, `tab.created`, `pane.created`, and `worktree.created`. `worktree.open` emits `worktree.opened`, and it also emits workspace/tab/pane creation events when it opens a new Herdr workspace. `worktree.remove` emits `worktree.removed`; if the linked workspace is still open, it also emits `workspace.closed`.
+## Agent view queries
+
+`agent.view.set` installs one transient declarative projection for the built-in
+Agents view. The projection is reevaluated whenever agent facts or current UI
+context change. It controls the expanded and collapsed sidebar, mobile Agents
+list, mouse targets, indexed focus, and next/previous Agent navigation. It does
+not change `agent.list`, notifications, detection, or global attention counts.
+
+Show agents in the currently presented Space or agents needing attention
+elsewhere, then order by attention and most recent state transition:
+
+```json
+{
+ "id": "view_set",
+ "method": "agent.view.set",
+ "params": {
+ "source": "plugin:example.agent-views",
+ "label": "focus",
+ "filter": {
+ "op": "any",
+ "filters": [
+ {
+ "op": "eq",
+ "field": "workspace_id",
+ "value": {"context": "current_workspace_id"}
+ },
+ {
+ "op": "in",
+ "field": "status",
+ "values": ["blocked", "done"]
+ }
+ ]
+ },
+ "sort": [
+ {"field": "attention", "order": "desc"},
+ {"field": "state_change_seq", "order": "desc"}
+ ]
+ }
+}
+```
+
+Filter nodes use `op` values `all`, `any`, `not`, `eq`, `in`, or `exists`.
+Built-in filter fields are `status`, `workspace_id`, `tab_id`, `pane_id`,
+`agent`, `seen`, and `state_change_seq`. Use `{"token":"name"}` as a field to
+filter plugin-reported pane metadata. Values are strings, booleans, unsigned
+numbers, or a context object. Context values are `current_workspace_id` and
+`current_tab_id`, and may only be compared to the matching ID field. Effective
+status values are `idle`, `working`, `blocked`, `done`, and `unknown`; `done`
+means idle and not yet seen.
+
+Sort fields are `workspace_order`, `tab_order`, `pane_order`, `attention`,
+`status`, `agent`, `seen`, `state_change_seq`, or `{"token":"name"}`. Sorts are
+stable, evaluated in order, and accept `asc` or `desc`. Missing values stay
+after present values. When `sort` is omitted, the existing
+`ui.agent_panel_sort` policy remains active. A custom sort temporarily replaces
+that policy without rewriting config.
+
+`source` identifies the owner. Plugins use `plugin:<HERDR_PLUGIN_ID>`; Herdr
+rejects plugin-owned sets when that plugin is missing or disabled. Other callers
+may use their own non-`plugin:` source. A successful set atomically replaces the
+previous view. The view lasts until it is cleared, replaced, its owning plugin
+is disabled, unlinked, or uninstalled, or the server exits. Plugins that want
+durable behavior should save the query under `HERDR_PLUGIN_STATE_DIR` and
+reapply it from a `[[startup]]` hook.
+
+Clear unconditionally, or only when the named source still owns the view:
+
+```json
+{"id":"view_clear","method":"agent.view.clear","params":{}}
+{"id":"view_clear_owned","method":"agent.view.clear","params":{"source":"plugin:example.agent-views"}}
+```
+
+A source mismatch leaves the active view unchanged. Set and clear responses use
+`type: "agent_view"` and report `active`, `source`, and optional `label`.
+
## Plugin APIs
The plugin API is an early host surface for executable workflow tools. A plugin
is a package with a `herdr-plugin.toml` manifest. The manifest declares
-shareable actions, event hooks, terminal pane entrypoints, and link handlers.
+startup hooks, shareable actions, event hooks, terminal pane entrypoints, and
+link handlers. Startup hooks run once after restore when the API is ready.
Actions and panes are manifest-only; runtime action registration and runtime
argv pane creation are not part of v1.
diff --git a/src/api/mod.rs b/src/api/mod.rs
index badf1707..994b2d87 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -42,6 +42,8 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::LayoutApply(_)
| Method::LayoutSetSplitRatio(_)
| Method::AgentRename(_)
+ | Method::AgentViewSet(_)
+ | Method::AgentViewClear(_)
| Method::AgentFocus(_)
| Method::AgentStart(_)
| Method::AgentPrompt(_)
@@ -67,6 +69,8 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::PaneReleaseAgent(_)
| Method::PaneClose(_)
| Method::PopupClose(_)
+ | Method::PluginUnlink(_)
+ | Method::PluginDisable(_)
| Method::PluginActionInvoke(_)
| Method::PluginPaneOpen(_)
| Method::PluginPaneFocus(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index efaee54c..72d9e53a 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -113,6 +113,10 @@ pub enum Method {
AgentSendKeys(AgentSendKeysParams),
#[serde(rename = "agent.rename")]
AgentRename(AgentRenameParams),
+ #[serde(rename = "agent.view.set")]
+ AgentViewSet(AgentViewSetParams),
+ #[serde(rename = "agent.view.clear")]
+ AgentViewClear(AgentViewClearParams),
#[serde(rename = "agent.focus")]
AgentFocus(AgentTarget),
#[serde(rename = "agent.start")]
diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs
index 59949293..9e1e7b15 100644
--- a/src/api/schema/agents.rs
+++ b/src/api/schema/agents.rs
@@ -47,6 +47,120 @@ pub struct AgentRenameParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct AgentViewSetParams {
+ pub source: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub filter: Option<AgentViewFilter>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub sort: Vec<AgentViewSort>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
+pub struct AgentViewClearParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(tag = "op", rename_all = "snake_case")]
+pub enum AgentViewFilter {
+ All {
+ filters: Vec<AgentViewFilter>,
+ },
+ Any {
+ filters: Vec<AgentViewFilter>,
+ },
+ Not {
+ filter: Box<AgentViewFilter>,
+ },
+ Eq {
+ field: AgentViewField,
+ value: AgentViewValue,
+ },
+ In {
+ field: AgentViewField,
+ values: Vec<AgentViewValue>,
+ },
+ Exists {
+ field: AgentViewField,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(untagged)]
+pub enum AgentViewField {
+ Builtin(AgentViewBuiltinField),
+ Token { token: String },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentViewBuiltinField {
+ Status,
+ WorkspaceId,
+ TabId,
+ PaneId,
+ Agent,
+ Seen,
+ StateChangeSeq,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(untagged)]
+pub enum AgentViewValue {
+ String(String),
+ Bool(bool),
+ Number(u64),
+ Context { context: AgentViewContext },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentViewContext {
+ CurrentWorkspaceId,
+ CurrentTabId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct AgentViewSort {
+ pub field: AgentViewSortField,
+ #[serde(default)]
+ pub order: AgentViewSortOrder,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(untagged)]
+pub enum AgentViewSortField {
+ Builtin(AgentViewBuiltinSortField),
+ Token { token: String },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentViewBuiltinSortField {
+ WorkspaceOrder,
+ TabOrder,
+ PaneOrder,
+ Attention,
+ Status,
+ Agent,
+ Seen,
+ StateChangeSeq,
+}
+
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentViewSortOrder {
+ #[default]
+ Asc,
+ Desc,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentStartParams {
pub name: String,
pub kind: String,
diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs
index 75b40f97..52df3ca6 100644
--- a/src/api/schema/plugins.rs
+++ b/src/api/schema/plugins.rs
@@ -50,6 +50,8 @@ pub struct InstalledPluginInfo {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub build: Vec<PluginManifestBuild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub startup: Vec<PluginManifestStartup>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<PluginManifestAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<PluginManifestEventHook>,
@@ -232,6 +234,13 @@ pub struct PluginManifestBuild {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct PluginManifestStartup {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub platforms: Option<Vec<PluginPlatform>>,
+ pub command: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginManifestAction {
pub id: String,
pub title: String,
diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs
index e1f47efd..db8cfc7b 100644
--- a/src/api/schema/response.rs
+++ b/src/api/schema/response.rs
@@ -107,6 +107,13 @@ pub enum ResponseResult {
AgentList {
agents: Vec<AgentInfo>,
},
+ AgentView {
+ active: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ source: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+ },
PaneInfo {
pane: PaneInfo,
},
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 236d4f12..9fcc03b0 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -296,6 +296,49 @@ fn client_window_title_requests_round_trip() {
}
#[test]
+fn agent_view_requests_round_trip() {
+ let set_json = serde_json::json!({
+ "id": "view-set",
+ "method": "agent.view.set",
+ "params": {
+ "source": "example.views",
+ "label": "current + attention",
+ "filter": {
+ "op": "any",
+ "filters": [
+ {
+ "op": "eq",
+ "field": "workspace_id",
+ "value": {"context": "current_workspace_id"}
+ },
+ {
+ "op": "in",
+ "field": "status",
+ "values": ["blocked", "done"]
+ }
+ ]
+ },
+ "sort": [
+ {"field": "attention", "order": "desc"},
+ {"field": "state_change_seq", "order": "desc"}
+ ]
+ }
+ });
+ let request: Request = serde_json::from_value(set_json.clone()).unwrap();
+ assert!(matches!(request.method, Method::AgentViewSet(_)));
+ assert_eq!(serde_json::to_value(request).unwrap(), set_json);
+
+ let clear_json = serde_json::json!({
+ "id": "view-clear",
+ "method": "agent.view.clear",
+ "params": {"source": "example.views"}
+ });
+ let request: Request = serde_json::from_value(clear_json.clone()).unwrap();
+ assert!(matches!(request.method, Method::AgentViewClear(_)));
+ assert_eq!(serde_json::to_value(request).unwrap(), clear_json);
+}
+
+#[test]
fn unknown_method_is_rejected() {
let json = r#"{"id":"req_1","method":"nope","params":{}}"#;
let err = serde_json::from_str::<Request>(json)
@@ -856,6 +899,7 @@ fn plugin_link_list_unlink_round_trip() {
platforms: None,
command: vec!["bun".into(), "install".into()],
}],
+ startup: vec![],
actions: vec![PluginManifestAction {
id: "bootstrap".into(),
title: "Bootstrap worktree".into(),
diff --git a/src/api/server.rs b/src/api/server.rs
index 0a36e2fa..5bc90ef9 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -378,6 +378,8 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentExplain(_) => "agent.explain",
Method::AgentSendKeys(_) => "agent.send_keys",
Method::AgentRename(_) => "agent.rename",
+ Method::AgentViewSet(_) => "agent.view.set",
+ Method::AgentViewClear(_) => "agent.view.clear",
Method::AgentFocus(_) => "agent.focus",
Method::AgentStart(_) => "agent.start",
Method::AgentPrompt(_) => "agent.prompt",
diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs
new file mode 100644
index 00000000..ba50ee3a
--- /dev/null
+++ b/src/app/agent_view.rs
@@ -0,0 +1,573 @@
+use std::cmp::Ordering;
+
+use crate::api::schema::{
+ AgentStatus, AgentViewBuiltinField, AgentViewBuiltinSortField, AgentViewContext,
+ AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewSort, AgentViewSortField,
+ AgentViewSortOrder, AgentViewValue,
+};
+use crate::ui::AgentPanelEntry;
+
+use super::{AppState, Mode};
+
+const MAX_FILTER_DEPTH: usize = 8;
+const MAX_FILTER_NODES: usize = 64;
+const MAX_FILTER_VALUES: usize = 32;
+const MAX_SORT_FIELDS: usize = 8;
+const MAX_SOURCE_CHARS: usize = 120;
+const MAX_LABEL_CHARS: usize = 32;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+enum EvalValue {
+ String(String),
+ Bool(bool),
+ Number(u64),
+}
+
+pub(crate) fn validate_agent_view(spec: &mut AgentViewSetParams) -> Result<(), String> {
+ spec.source = normalize_source(&spec.source)?;
+ spec.label = spec
+ .label
+ .take()
+ .map(|label| normalize_label(&label))
+ .transpose()?;
+
+ let mut nodes = 0;
+ if let Some(filter) = &spec.filter {
+ validate_filter(filter, 1, &mut nodes)?;
+ }
+ if spec.sort.len() > MAX_SORT_FIELDS {
+ return Err(format!(
+ "agent view sort may contain at most {MAX_SORT_FIELDS} fields"
+ ));
+ }
+ for sort in &spec.sort {
+ validate_sort_field(&sort.field)?;
+ }
+ Ok(())
+}
+
+pub(crate) fn validate_agent_view_source(source: &str) -> Result<String, String> {
+ normalize_source(source)
+}
+
+pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec<AgentPanelEntry>) {
+ if let Some(spec) = app.agent_view_override.as_ref() {
+ if let Some(filter) = &spec.filter {
+ entries.retain(|entry| matches_filter(app, entry, filter));
+ }
+ if !spec.sort.is_empty() {
+ entries.sort_by(|left, right| compare_entries(app, left, right, &spec.sort));
+ return;
+ }
+ }
+
+ if matches!(
+ app.agent_panel_sort,
+ crate::app::state::AgentPanelSort::Priority
+ ) {
+ entries.sort_by_key(|entry| {
+ (
+ std::cmp::Reverse(super::api_helpers::tab_attention_priority(
+ entry.state,
+ entry.seen,
+ )),
+ std::cmp::Reverse(entry.last_agent_state_change_seq),
+ )
+ });
+ }
+}
+
+pub(crate) fn presented_workspace_idx(app: &AppState) -> Option<usize> {
+ if app.mode == Mode::Navigate {
+ app.workspaces.get(app.selected).map(|_| app.selected)
+ } else {
+ app.active
+ }
+}
+
+fn normalize_source(source: &str) -> Result<String, String> {
+ let source = source.trim();
+ if source.is_empty()
+ || source.chars().count() > MAX_SOURCE_CHARS
+ || !source
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-'))
+ {
+ return Err(format!(
+ "agent view source must be non-empty, at most {MAX_SOURCE_CHARS} characters, and contain only ASCII letters, digits, colon, dot, underscore, or hyphen"
+ ));
+ }
+ Ok(source.to_string())
+}
+
+fn normalize_label(label: &str) -> Result<String, String> {
+ let label = label
+ .trim()
+ .chars()
+ .filter(|ch| !ch.is_control())
+ .collect::<String>();
+ if label.is_empty() || label.chars().count() > MAX_LABEL_CHARS {
+ return Err(format!(
+ "agent view label must be non-empty and at most {MAX_LABEL_CHARS} characters"
+ ));
+ }
+ Ok(label)
+}
+
+fn validate_filter(
+ filter: &AgentViewFilter,
+ depth: usize,
+ nodes: &mut usize,
+) -> Result<(), String> {
+ if depth > MAX_FILTER_DEPTH {
+ return Err(format!(
+ "agent view filter may be nested at most {MAX_FILTER_DEPTH} levels"
+ ));
+ }
+ *nodes += 1;
+ if *nodes > MAX_FILTER_NODES {
+ return Err(format!(
+ "agent view filter may contain at most {MAX_FILTER_NODES} nodes"
+ ));
+ }
+
+ match filter {
+ AgentViewFilter::All { filters } | AgentViewFilter::Any { filters } => {
+ if filters.is_empty() {
+ return Err("agent view all/any filters must not be empty".to_string());
+ }
+ for filter in filters {
+ validate_filter(filter, depth + 1, nodes)?;
+ }
+ }
+ AgentViewFilter::Not { filter } => validate_filter(filter, depth + 1, nodes)?,
+ AgentViewFilter::Eq { field, value } => validate_field_value(field, value)?,
+ AgentViewFilter::In { field, values } => {
+ if values.is_empty() || values.len() > MAX_FILTER_VALUES {
+ return Err(format!(
+ "agent view in filters require 1 to {MAX_FILTER_VALUES} values"
+ ));
+ }
+ for value in values {
+ validate_field_value(field, value)?;
+ }
+ }
+ AgentViewFilter::Exists { field } => validate_field(field)?,
+ }
+ Ok(())
+}
+
+fn validate_field(field: &AgentViewField) -> Result<(), String> {
+ if let AgentViewField::Token { token } = field {
+ validate_token(token)?;
+ }
+ Ok(())
+}
+
+fn validate_field_value(field: &AgentViewField, value: &AgentViewValue) -> Result<(), String> {
+ validate_field(field)?;
+ match (field, value) {
+ (
+ AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
+ AgentViewValue::Context {
+ context: AgentViewContext::CurrentWorkspaceId,
+ },
+ )
+ | (
+ AgentViewField::Builtin(AgentViewBuiltinField::TabId),
+ AgentViewValue::Context {
+ context: AgentViewContext::CurrentTabId,
+ },
+ ) => Ok(()),
+ (_, AgentViewValue::Context { .. }) => {
+ Err("agent view context type does not match the selected field".to_string())
+ }
+ (AgentViewField::Builtin(AgentViewBuiltinField::Seen), AgentViewValue::Bool(_))
+ | (
+ AgentViewField::Builtin(AgentViewBuiltinField::StateChangeSeq),
+ AgentViewValue::Number(_),
+ ) => Ok(()),
+ (
+ AgentViewField::Builtin(
+ AgentViewBuiltinField::Status
+ | AgentViewBuiltinField::WorkspaceId
+ | AgentViewBuiltinField::TabId
+ | AgentViewBuiltinField::PaneId
+ | AgentViewBuiltinField::Agent,
+ )
+ | AgentViewField::Token { .. },
+ AgentViewValue::String(value),
+ ) => {
+ if matches!(
+ field,
+ AgentViewField::Builtin(AgentViewBuiltinField::Status)
+ ) && !matches!(
+ value.as_str(),
+ "idle" | "working" | "blocked" | "done" | "unknown"
+ ) {
+ return Err(format!("unknown agent status `{value}`"));
+ }
+ Ok(())
+ }
+ _ => Err("agent view value type does not match the selected field".to_string()),
+ }
+}
+
+fn validate_sort_field(field: &AgentViewSortField) -> Result<(), String> {
+ if let AgentViewSortField::Token { token } = field {
+ validate_token(token)?;
+ }
+ Ok(())
+}
+
+fn validate_token(token: &str) -> Result<(), String> {
+ if token.is_empty()
+ || token.len() > 32
+ || !token
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
+ {
+ return Err(format!("invalid agent view token `{token}`"));
+ }
+ Ok(())
+}
+
+fn matches_filter(app: &AppState, entry: &AgentPanelEntry, filter: &AgentViewFilter) -> bool {
+ match filter {
+ AgentViewFilter::All { filters } => filters
+ .iter()
+ .all(|filter| matches_filter(app, entry, filter)),
+ AgentViewFilter::Any { filters } => filters
+ .iter()
+ .any(|filter| matches_filter(app, entry, filter)),
+ AgentViewFilter::Not { filter } => !matches_filter(app, entry, filter),
+ AgentViewFilter::Eq { field, value } => {
+ field_value(app, entry, field) == operand_value(app, value)
+ }
+ AgentViewFilter::In { field, values } => {
+ let actual = field_value(app, entry, field);
+ values
+ .iter()
+ .any(|value| actual == operand_value(app, value))
+ }
+ AgentViewFilter::Exists { field } => field_value(app, entry, field).is_some(),
+ }
+}
+
+fn compare_entries(
+ app: &AppState,
+ left: &AgentPanelEntry,
+ right: &AgentPanelEntry,
+ sorts: &[AgentViewSort],
+) -> Ordering {
+ for sort in sorts {
+ let left = sort_value(app, left, &sort.field);
+ let right = sort_value(app, right, &sort.field);
+ let ordering = compare_optional_values(left, right, sort.order);
+ if ordering != Ordering::Equal {
+ return ordering;
+ }
+ }
+ Ordering::Equal
+}
+
+fn compare_optional_values(
+ left: Option<EvalValue>,
+ right: Option<EvalValue>,
+ order: AgentViewSortOrder,
+) -> Ordering {
+ match (left, right) {
+ (Some(left), Some(right)) => {
+ let ordering = left.cmp(&right);
+ if matches!(order, AgentViewSortOrder::Desc) {
+ ordering.reverse()
+ } else {
+ ordering
+ }
+ }
+ (Some(_), None) => Ordering::Less,
+ (None, Some(_)) => Ordering::Greater,
+ (None, None) => Ordering::Equal,
+ }
+}
+
+fn field_value(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ field: &AgentViewField,
+) -> Option<EvalValue> {
+ match field {
+ AgentViewField::Builtin(field) => builtin_field_value(app, entry, *field),
+ AgentViewField::Token { token } => entry.tokens.get(token).cloned().map(EvalValue::String),
+ }
+}
+
+fn builtin_field_value(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ field: AgentViewBuiltinField,
+) -> Option<EvalValue> {
+ match field {
+ AgentViewBuiltinField::Status => {
+ Some(EvalValue::String(status_name(entry.state, entry.seen)))
+ }
+ AgentViewBuiltinField::WorkspaceId => app
+ .workspaces
+ .get(entry.ws_idx)
+ .map(|workspace| EvalValue::String(workspace.id.clone())),
+ AgentViewBuiltinField::TabId => public_tab_id(app, entry).map(EvalValue::String),
+ AgentViewBuiltinField::PaneId => public_pane_id(app, entry).map(EvalValue::String),
+ AgentViewBuiltinField::Agent => entry.agent_kind_label.clone().map(EvalValue::String),
+ AgentViewBuiltinField::Seen => Some(EvalValue::Bool(entry.seen)),
+ AgentViewBuiltinField::StateChangeSeq => {
+ entry.last_agent_state_change_seq.map(EvalValue::Number)
+ }
+ }
+}
+
+fn operand_value(app: &AppState, value: &AgentViewValue) -> Option<EvalValue> {
+ match value {
+ AgentViewValue::String(value) => Some(EvalValue::String(value.clone())),
+ AgentViewValue::Bool(value) => Some(EvalValue::Bool(*value)),
+ AgentViewValue::Number(value) => Some(EvalValue::Number(*value)),
+ AgentViewValue::Context { context } => context_value(app, *context),
+ }
+}
+
+fn context_value(app: &AppState, context: AgentViewContext) -> Option<EvalValue> {
+ let ws_idx = presented_workspace_idx(app)?;
+ let workspace = app.workspaces.get(ws_idx)?;
+ match context {
+ AgentViewContext::CurrentWorkspaceId => Some(EvalValue::String(workspace.id.clone())),
+ AgentViewContext::CurrentTabId => {
+ let tab_number = workspace.public_tab_number(workspace.active_tab)?;
+ Some(EvalValue::String(
+ crate::workspace::public_tab_id_for_number(&workspace.id, tab_number),
+ ))
+ }
+ }
+}
+
+fn sort_value(
+ app: &AppState,
+ entry: &AgentPanelEntry,
+ field: &AgentViewSortField,
+) -> Option<EvalValue> {
+ match field {
+ AgentViewSortField::Token { token } => {
+ entry.tokens.get(token).cloned().map(EvalValue::String)
+ }
+ AgentViewSortField::Builtin(field) => match field {
+ AgentViewBuiltinSortField::WorkspaceOrder => {
+ Some(EvalValue::Number(entry.ws_idx as u64))
+ }
+ AgentViewBuiltinSortField::TabOrder => app
+ .workspaces
+ .get(entry.ws_idx)
+ .and_then(|workspace| workspace.public_tab_number(entry.tab_idx))
+ .map(|number| EvalValue::Number(number as u64)),
+ AgentViewBuiltinSortField::PaneOrder => app
+ .workspaces
+ .get(entry.ws_idx)
+ .and_then(|workspace| workspace.public_pane_number(entry.pane_id))
+ .map(|number| EvalValue::Number(number as u64)),
+ AgentViewBuiltinSortField::Attention => Some(EvalValue::Number(u64::from(
+ super::api_helpers::tab_attention_priority(entry.state, entry.seen),
+ ))),
+ AgentViewBuiltinSortField::Status => {
+ Some(EvalValue::String(status_name(entry.state, entry.seen)))
+ }
+ AgentViewBuiltinSortField::Agent => {
+ entry.agent_kind_label.clone().map(EvalValue::String)
+ }
+ AgentViewBuiltinSortField::Seen => Some(EvalValue::Bool(entry.seen)),
+ AgentViewBuiltinSortField::StateChangeSeq => {
+ entry.last_agent_state_change_seq.map(EvalValue::Number)
+ }
+ },
+ }
+}
+
+fn status_name(state: crate::detect::AgentState, seen: bool) -> String {
+ let status = match (state, seen) {
+ (crate::detect::AgentState::Idle, false) => AgentStatus::Done,
+ (crate::detect::AgentState::Idle, true) => AgentStatus::Idle,
+ (crate::detect::AgentState::Working, _) => AgentStatus::Working,
+ (crate::detect::AgentState::Blocked, _) => AgentStatus::Blocked,
+ (crate::detect::AgentState::Unknown, _) => AgentStatus::Unknown,
+ };
+ match status {
+ AgentStatus::Idle => "idle",
+ AgentStatus::Working => "working",
+ AgentStatus::Blocked => "blocked",
+ AgentStatus::Done => "done",
+ AgentStatus::Unknown => "unknown",
+ }
+ .to_string()
+}
+
+fn public_tab_id(app: &AppState, entry: &AgentPanelEntry) -> Option<String> {
+ let workspace = app.workspaces.get(entry.ws_idx)?;
+ let number = workspace.public_tab_number(entry.tab_idx)?;
+ Some(crate::workspace::public_tab_id_for_number(
+ &workspace.id,
+ number,
+ ))
+}
+
+fn public_pane_id(app: &AppState, entry: &AgentPanelEntry) -> Option<String> {
+ let workspace = app.workspaces.get(entry.ws_idx)?;
+ let number = workspace.public_pane_number(entry.pane_id)?;
+ Some(crate::workspace::public_pane_id_for_number(
+ &workspace.id,
+ number,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{AgentViewBuiltinSortField, AgentViewSortField};
+ use crate::detect::{Agent, AgentState};
+ use crate::workspace::Workspace;
+
+ fn state_with_agents() -> AppState {
+ let mut state = AppState::test_new();
+ state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ state.ensure_test_terminals();
+ state.active = Some(0);
+ state.selected = 0;
+ for (ws_idx, agent_state) in [(0, AgentState::Idle), (1, AgentState::Working)] {
+ let pane_id = state.workspaces[ws_idx].tabs[0].root_pane;
+ let terminal_id = state.workspaces[ws_idx].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(Agent::Claude);
+ terminal.state = agent_state;
+ }
+ state
+ }
+
+ fn current_workspace_view() -> AgentViewSetParams {
+ AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: Some("current".to_string()),
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
+ value: AgentViewValue::Context {
+ context: AgentViewContext::CurrentWorkspaceId,
+ },
+ }),
+ sort: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn current_workspace_filter_tracks_presented_workspace() {
+ let mut state = state_with_agents();
+ state.agent_view_override = Some(current_workspace_view());
+
+ assert_eq!(crate::ui::agent_panel_entries(&state)[0].ws_idx, 0);
+
+ state.mode = Mode::Navigate;
+ state.selected = 1;
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].ws_idx, 1);
+
+ state.mode = Mode::Settings;
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].ws_idx, 0);
+ }
+
+ #[test]
+ fn boolean_filter_and_custom_sort_define_canonical_entries() {
+ let mut state = state_with_agents();
+ let first_pane = state.workspaces[0].tabs[0].root_pane;
+ let first_terminal = state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&first_terminal).unwrap().state = AgentState::Working;
+ state.agent_view_override = Some(AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(AgentViewFilter::All {
+ filters: vec![
+ AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
+ value: AgentViewValue::String("working".to_string()),
+ },
+ AgentViewFilter::Not {
+ filter: Box::new(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId),
+ value: AgentViewValue::String("missing".to_string()),
+ }),
+ },
+ ],
+ }),
+ sort: vec![AgentViewSort {
+ field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::WorkspaceOrder),
+ order: AgentViewSortOrder::Desc,
+ }],
+ });
+
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].ws_idx, 1);
+ assert_eq!(entries[1].ws_idx, 0);
+ }
+
+ #[test]
+ fn agent_filter_matches_custom_lifecycle_agent_label() {
+ let mut state = state_with_agents();
+ let pane_id = state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_hook_authority(
+ "test".to_string(),
+ "custom-agent".to_string(),
+ AgentState::Working,
+ None,
+ None,
+ );
+ state.agent_view_override = Some(AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Agent),
+ value: AgentViewValue::String("custom-agent".to_string()),
+ }),
+ sort: Vec::new(),
+ });
+
+ let entries = crate::ui::agent_panel_entries(&state);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].agent_kind_label.as_deref(), Some("custom-agent"));
+ }
+
+ #[test]
+ fn validation_rejects_mismatched_context_type() {
+ let mut spec = AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
+ value: AgentViewValue::Context {
+ context: AgentViewContext::CurrentWorkspaceId,
+ },
+ }),
+ sort: Vec::new(),
+ };
+
+ assert!(validate_agent_view(&mut spec)
+ .unwrap_err()
+ .contains("context type"));
+ }
+}
diff --git a/src/app/api.rs b/src/app/api.rs
index 7c963ef4..0cf55fd5 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -1,6 +1,7 @@
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
+mod agent_view;
mod agents;
mod env;
mod integrations;
@@ -1000,6 +1001,10 @@ impl App {
Method::AgentGet(target) => return self.handle_agent_get(request.id, target),
Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target),
Method::AgentRename(params) => return self.handle_agent_rename(request.id, params),
+ Method::AgentViewSet(params) => return self.handle_agent_view_set(request.id, params),
+ Method::AgentViewClear(params) => {
+ return self.handle_agent_view_clear(request.id, params)
+ }
Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
Method::AgentPrompt(params) => return self.handle_agent_prompt(request.id, params),
Method::AgentWait(_) => {
diff --git a/src/app/api/agent_view.rs b/src/app/api/agent_view.rs
new file mode 100644
index 00000000..e14a933d
--- /dev/null
+++ b/src/app/api/agent_view.rs
@@ -0,0 +1,185 @@
+use crate::api::schema::{AgentViewClearParams, AgentViewSetParams, ResponseResult};
+use crate::app::App;
+
+use super::responses::{encode_error, encode_success};
+
+impl App {
+ pub(super) fn handle_agent_view_set(
+ &mut self,
+ id: String,
+ mut params: AgentViewSetParams,
+ ) -> String {
+ if let Err(message) = crate::app::agent_view::validate_agent_view(&mut params) {
+ return encode_error(id, "invalid_agent_view", message);
+ }
+ if let Some(plugin_id) = params.source.strip_prefix("plugin:") {
+ let Some(plugin_id) = super::plugins::normalize_plugin_id(plugin_id) else {
+ return encode_error(
+ id,
+ "invalid_agent_view",
+ "plugin-owned agent view source has an invalid plugin id",
+ );
+ };
+ let Some(plugin) = self.state.installed_plugins.get(&plugin_id) else {
+ return encode_error(id, "plugin_not_found", "plugin not found");
+ };
+ if !plugin.enabled {
+ return encode_error(id, "plugin_disabled", "plugin is disabled");
+ }
+ }
+ let source = params.source.clone();
+ let label = params.label.clone();
+ self.replace_agent_view_override(Some(params));
+ encode_success(
+ id,
+ ResponseResult::AgentView {
+ active: true,
+ source: Some(source),
+ label,
+ },
+ )
+ }
+
+ pub(super) fn handle_agent_view_clear(
+ &mut self,
+ id: String,
+ params: AgentViewClearParams,
+ ) -> String {
+ let source = match params.source {
+ Some(source) => match crate::app::agent_view::validate_agent_view_source(&source) {
+ Ok(source) => Some(source),
+ Err(message) => return encode_error(id, "invalid_agent_view", message),
+ },
+ None => None,
+ };
+ if source.as_deref().is_none_or(|source| {
+ self.state
+ .agent_view_override
+ .as_ref()
+ .is_some_and(|active| active.source == source)
+ }) {
+ self.replace_agent_view_override(None);
+ }
+ let active = self.state.agent_view_override.as_ref();
+ encode_success(
+ id,
+ ResponseResult::AgentView {
+ active: active.is_some(),
+ source: active.map(|view| view.source.clone()),
+ label: active.and_then(|view| view.label.clone()),
+ },
+ )
+ }
+
+ pub(crate) fn clear_agent_view_for_source(&mut self, source: &str) -> bool {
+ if self
+ .state
+ .agent_view_override
+ .as_ref()
+ .is_some_and(|active| active.source == source)
+ {
+ self.replace_agent_view_override(None);
+ true
+ } else {
+ false
+ }
+ }
+
+ fn replace_agent_view_override(&mut self, view: Option<AgentViewSetParams>) {
+ self.state.agent_view_override = view;
+ self.state.agent_panel_scroll = 0;
+ self.state.mobile_switcher_scroll = 0;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{
+ AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue,
+ };
+
+ fn test_app() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ fn working_view(source: &str) -> AgentViewSetParams {
+ AgentViewSetParams {
+ source: source.to_string(),
+ label: Some("working".to_string()),
+ filter: Some(AgentViewFilter::Eq {
+ field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
+ value: AgentViewValue::String("working".to_string()),
+ }),
+ sort: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn set_and_source_guarded_clear_replace_transient_view() {
+ let mut app = test_app();
+
+ let set = app.handle_agent_view_set("set".to_string(), working_view("example.views"));
+ let set: crate::api::schema::SuccessResponse = serde_json::from_str(&set).unwrap();
+ assert_eq!(
+ set.result,
+ ResponseResult::AgentView {
+ active: true,
+ source: Some("example.views".to_string()),
+ label: Some("working".to_string()),
+ }
+ );
+ assert_eq!(
+ app.state
+ .agent_view_override
+ .as_ref()
+ .map(|view| view.source.as_str()),
+ Some("example.views")
+ );
+
+ app.handle_agent_view_clear(
+ "wrong-source".to_string(),
+ AgentViewClearParams {
+ source: Some("other.views".to_string()),
+ },
+ );
+ assert!(app.state.agent_view_override.is_some());
+
+ app.handle_agent_view_clear(
+ "right-source".to_string(),
+ AgentViewClearParams {
+ source: Some("example.views".to_string()),
+ },
+ );
+ assert!(app.state.agent_view_override.is_none());
+ }
+
+ #[test]
+ fn invalid_view_does_not_replace_active_view() {
+ let mut app = test_app();
+ app.handle_agent_view_set("set".to_string(), working_view("example.views"));
+
+ let mut invalid = working_view("example.other");
+ invalid.filter = Some(AgentViewFilter::Any {
+ filters: Vec::new(),
+ });
+ let response = app.handle_agent_view_set("invalid".to_string(), invalid);
+ let response: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response.error.code, "invalid_agent_view");
+ assert_eq!(
+ app.state
+ .agent_view_override
+ .as_ref()
+ .map(|view| view.source.as_str()),
+ Some("example.views")
+ );
+ }
+}
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs
index 645748e0..f6464285 100644
--- a/src/app/api/plugins/manifest.rs
+++ b/src/app/api/plugins/manifest.rs
@@ -1,7 +1,7 @@
use crate::api::schema::{
InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook,
- PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform,
- PluginSourceInfo, PluginSourceKind,
+ PluginManifestLinkHandler, PluginManifestPane, PluginManifestStartup, PluginPanePlacement,
+ PluginPlatform, PluginSourceInfo, PluginSourceKind,
};
use crate::popup_size::PopupSize;
@@ -22,6 +22,8 @@ struct RawPluginManifest {
#[serde(default)]
build: Vec<RawPluginManifestBuild>,
#[serde(default)]
+ startup: Vec<RawPluginManifestStartup>,
+ #[serde(default)]
actions: Vec<RawPluginManifestAction>,
#[serde(default)]
events: Vec<RawPluginManifestEventHook>,
@@ -39,6 +41,13 @@ struct RawPluginManifestBuild {
}
#[derive(serde::Deserialize)]
+struct RawPluginManifestStartup {
+ #[serde(default)]
+ platforms: Option<Vec<RawPlatform>>,
+ command: Vec<String>,
+}
+
+#[derive(serde::Deserialize)]
struct RawPluginManifestAction {
id: String,
title: String,
@@ -151,6 +160,11 @@ pub(crate) fn load_plugin_manifest(
.into_iter()
.map(normalize_manifest_build)
.collect::<Result<Vec<_>, _>>()?;
+ let startup = raw
+ .startup
+ .into_iter()
+ .map(normalize_manifest_startup)
+ .collect::<Result<Vec<_>, _>>()?;
let mut actions = raw
.actions
.into_iter()
@@ -202,6 +216,7 @@ pub(crate) fn load_plugin_manifest(
enabled,
platforms,
build,
+ startup,
actions,
events,
panes,
@@ -250,6 +265,14 @@ fn normalize_manifest_build(
Ok(PluginManifestBuild { platforms, command })
}
+fn normalize_manifest_startup(
+ startup: RawPluginManifestStartup,
+) -> Result<PluginManifestStartup, (&'static str, String)> {
+ let platforms = normalize_platforms(startup.platforms)?;
+ let command = normalize_command(startup.command)?;
+ Ok(PluginManifestStartup { platforms, command })
+}
+
pub(super) fn normalize_plugin_source(
plugin: &InstalledPluginInfo,
source: PluginSourceInfo,
@@ -563,7 +586,7 @@ fn non_empty_trimmed(
}
}
-pub(super) fn normalize_plugin_id(value: &str) -> Option<String> {
+pub(crate) fn normalize_plugin_id(value: &str) -> Option<String> {
normalize_identifier(value, PLUGIN_ID_MAX_CHARS)
}
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index 83a6065e..e48a1506 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -13,9 +13,9 @@ use crate::api::schema::{
ResponseResult,
};
use crate::app::App;
+pub(super) use manifest::normalize_plugin_id;
use manifest::{
- effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_id,
- normalize_plugin_source,
+ effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_source,
};
#[cfg(test)]
@@ -110,6 +110,7 @@ impl App {
}
return encode_error(id, "plugin_registry_save_failed", err.to_string());
}
+ self.clear_agent_view_for_source(&format!("plugin:{plugin_id}"));
}
encode_success(id, ResponseResult::PluginUnlinked { plugin_id, removed })
}
@@ -593,6 +594,9 @@ impl App {
let Some(plugin) = self.state.installed_plugins.get(&plugin_id).cloned() else {
return encode_error(id, "plugin_not_found", "plugin not found");
};
+ if !enabled {
+ self.clear_agent_view_for_source(&format!("plugin:{plugin_id}"));
+ }
if enabled {
encode_success(id, ResponseResult::PluginEnabled { plugin })
} else {
@@ -1212,6 +1216,38 @@ command = ["echo", "b"]
}
#[test]
+ fn startup_hook_manifest_loads() {
+ let root = unique_temp_path("plugin-startup-manifest");
+ write_manifest_content(
+ &root,
+ r#"
+id = "example.startup-manifest"
+name = "Startup Manifest"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[startup]]
+command = ["node", "restore.js"]
+platforms = ["linux", "macos"]
+"#,
+ );
+
+ let plugin = load_plugin_manifest(&root.display().to_string(), true).unwrap();
+
+ assert_eq!(plugin.startup.len(), 1);
+ assert_eq!(plugin.startup[0].command, ["node", "restore.js"]);
+ assert_eq!(
+ plugin.startup[0].platforms,
+ Some(vec![
+ crate::api::schema::PluginPlatform::Linux,
+ crate::api::schema::PluginPlatform::Macos,
+ ])
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
fn smoke_fixture_manifest_loads() {
let plugin = load_plugin_manifest("tests/fixtures/plugin-smoke", true)
.expect("smoke fixture should load");
@@ -1235,6 +1271,12 @@ command = ["echo", "b"]
let root = unique_temp_path("plugin-enable-disable");
write_manifest(&root);
link_manifest(&mut app, &root);
+ app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams {
+ source: "plugin:example.worktree-bootstrap".into(),
+ label: None,
+ filter: None,
+ sort: Vec::new(),
+ });
let disabled = app.handle_api_request(Request {
id: "disable".into(),
@@ -1246,6 +1288,19 @@ command = ["echo", "b"]
panic!("expected disabled response: {disabled}");
};
assert!(!plugin.enabled);
+ assert!(app.state.agent_view_override.is_none());
+ let delayed_restore = app.handle_api_request(Request {
+ id: "delayed-startup-restore".into(),
+ method: Method::AgentViewSet(crate::api::schema::AgentViewSetParams {
+ source: "plugin:example.worktree-bootstrap".into(),
+ label: None,
+ filter: None,
+ sort: Vec::new(),
+ }),
+ });
+ let delayed_restore: crate::api::schema::ErrorResponse =
+ serde_json::from_str(&delayed_restore).unwrap();
+ assert_eq!(delayed_restore.error.code, "plugin_disabled");
let enabled = app.handle_api_request(Request {
id: "enable".into(),
@@ -2266,6 +2321,43 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG
#[cfg(unix)]
#[test]
+ fn startup_hooks_run_once_with_plugin_environment() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-startup-hook");
+ let capture = root.join("startup.txt");
+ write_manifest_content(
+ &root,
+ &format!(
+ r#"
+id = "example.startup"
+name = "Startup"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos"]
+
+[[startup]]
+command = ["sh", "-c", "printf '%s:%s' \"$HERDR_PLUGIN_ID\" \"$HERDR_PLUGIN_EVENT\" > {}"]
+"#,
+ capture.display()
+ ),
+ );
+ link_manifest(&mut app, &root);
+
+ app.run_plugin_startup_hooks();
+
+ assert_eq!(
+ read_capture_when_ready(&capture, || {
+ app.drain_all_internal_events();
+ }),
+ "example.startup:startup"
+ );
+ let plugin = app.state.installed_plugins.get("example.startup").unwrap();
+ assert_eq!(plugin.startup.len(), 1);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[cfg(unix)]
+ #[test]
fn event_hooks_use_event_target_context() {
let mut app = test_app();
app.state.workspaces = vec![
diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs
index 0008def2..71004fe5 100644
--- a/src/app/api/plugins/runtime.rs
+++ b/src/app/api/plugins/runtime.rs
@@ -180,6 +180,41 @@ impl App {
Ok(log)
}
+ pub(crate) fn run_plugin_startup_hooks(&mut self) {
+ let mut context = self.current_plugin_context("plugin.startup");
+ context.invocation_source = Some("startup".to_string());
+ let mut plugins = self
+ .state
+ .installed_plugins
+ .values()
+ .filter(|plugin| {
+ plugin.enabled && plugin_manifest_available(plugin) && !plugin.startup.is_empty()
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ plugins.sort_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
+ for plugin in plugins {
+ for startup in plugin.startup.clone() {
+ if ensure_platform_supported(
+ &effective_platforms(&startup.platforms, &plugin.platforms).clone(),
+ "startup",
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let _ = self.start_plugin_command(
+ &plugin,
+ None,
+ Some("startup".to_string()),
+ startup.command,
+ &context,
+ None,
+ );
+ }
+ }
+ }
+
pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) {
let event_name = event.event.dot_name();
if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) {
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index 2f143981..91a19673 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -828,6 +828,7 @@ mod tests {
enabled: true,
platforms: None,
build: Vec::new(),
+ startup: Vec::new(),
actions: Vec::new(),
events: vec![crate::api::schema::PluginManifestEventHook {
on: event.into(),
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index e1ffeb5d..d151b94d 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -724,14 +724,13 @@ impl App {
.and_then(crate::workspace::Workspace::focused_pane_id);
let current_idx = entries
.iter()
- .position(|entry| Some(entry.pane_id) == focused)
- .unwrap_or(0);
- let next_idx = if forward {
- (current_idx + 1) % entries.len()
- } else if current_idx == 0 {
- entries.len() - 1
- } else {
- current_idx - 1
+ .position(|entry| Some(entry.pane_id) == focused);
+ let next_idx = match (current_idx, forward) {
+ (Some(idx), true) => (idx + 1) % entries.len(),
+ (Some(0), false) => entries.len() - 1,
+ (Some(idx), false) => idx - 1,
+ (None, true) => 0,
+ (None, false) => entries.len() - 1,
};
let target = entries.get(next_idx)?;
Some((next_idx, target.ws_idx, target.pane_id))
@@ -1877,6 +1876,39 @@ mod tests {
}
#[test]
+ fn next_agent_starts_at_first_visible_entry_when_focused_agent_is_filtered_out() {
+ let mut app = app_with_test_workspaces(&["hidden", "first", "second"]);
+ for ws_idx in 0..app.state.workspaces.len() {
+ let pane_id = app.state.workspaces[ws_idx].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(crate::detect::Agent::Claude);
+ terminal.state = if ws_idx == 0 {
+ crate::detect::AgentState::Idle
+ } else {
+ crate::detect::AgentState::Working
+ };
+ }
+ app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(crate::api::schema::AgentViewFilter::Eq {
+ field: crate::api::schema::AgentViewField::Builtin(
+ crate::api::schema::AgentViewBuiltinField::Status,
+ ),
+ value: crate::api::schema::AgentViewValue::String("working".to_string()),
+ }),
+ sort: Vec::new(),
+ });
+
+ app.execute_tui_navigate_action(NavigateAction::NextAgent, ActionContext::Prefix);
+
+ assert_eq!(app.state.active, Some(1));
+ }
+
+ #[test]
fn default_goto_key_opens_navigator() {
let mut state = state_with_workspaces(&["test"]);
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 9ae30763..85de8aac 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -413,7 +413,7 @@ impl AppState {
}
pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed {
+ if self.sidebar_collapsed || self.agent_view_override.is_some() {
return false;
}
@@ -450,7 +450,8 @@ impl AppState {
let mut row_y = body.y;
let body_bottom = body.y + body.height;
let entries = crate::ui::agent_panel_entries(self);
- for (index, detail) in entries.iter().enumerate().skip(self.agent_panel_scroll) {
+ let scroll = self.agent_panel_scroll.min(metrics.max_offset_from_bottom);
+ for (index, detail) in entries.iter().enumerate().skip(scroll) {
let height = crate::ui::agent_entry_height_in_body(self, detail, body.height);
if row_y.saturating_add(height) > body_bottom {
break;
@@ -746,6 +747,50 @@ mod tests {
}
#[test]
+ fn agent_hit_testing_clamps_scroll_after_dynamic_filter_shrink() {
+ 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();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ for (ws_idx, pane_id) in [(0, first_pane), (1, second_pane)] {
+ 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::Claude);
+ }
+ app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(crate::api::schema::AgentViewFilter::Eq {
+ field: crate::api::schema::AgentViewField::Builtin(
+ crate::api::schema::AgentViewBuiltinField::WorkspaceId,
+ ),
+ value: crate::api::schema::AgentViewValue::Context {
+ context: crate::api::schema::AgentViewContext::CurrentWorkspaceId,
+ },
+ }),
+ sort: Vec::new(),
+ });
+ app.state.agent_panel_scroll = 10;
+ let detail_area = app.state.agent_panel_rect();
+ let body = crate::ui::agent_panel_body_rect(detail_area, false);
+
+ assert_eq!(
+ app.state.agent_detail_target_at(body.y),
+ Some((0, 0, first_pane))
+ );
+ }
+
+ #[test]
fn clicking_agent_panel_toggle_switches_sort() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("test")];
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 587c0e22..c1bfac87 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -376,6 +376,7 @@ mod tests {
enabled: true,
platforms: None,
build: Vec::new(),
+ startup: Vec::new(),
actions: vec![crate::api::schema::PluginManifestAction {
id: "open".into(),
title: "Open link".into(),
diff --git a/src/app/mod.rs b/src/app/mod.rs
index bff7c865..8a9e7389 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -6,6 +6,7 @@
pub(crate) mod actions;
mod agent_resume;
+pub(crate) mod agent_view;
mod agents;
mod api;
mod api_helpers;
@@ -607,6 +608,7 @@ impl App {
sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode,
sidebar_section_split,
agent_panel_sort,
+ agent_view_override: None,
sidebar_agents: config.ui.sidebar.agents.clone(),
sidebar_spaces: config.ui.sidebar.spaces.clone(),
next_agent_state_change_seq: 0,
@@ -3609,6 +3611,12 @@ mod tests {
amount: Some(0.05),
}),
};
+ let agent_view = crate::api::schema::Request {
+ id: "req_9".into(),
+ method: crate::api::schema::Method::AgentViewClear(
+ crate::api::schema::AgentViewClearParams::default(),
+ ),
+ };
assert!(!crate::api::request_changes_ui(&read_only));
assert!(!crate::api::request_changes_ui(&worktree_list));
@@ -3618,6 +3626,7 @@ mod tests {
assert!(crate::api::request_changes_ui(&pane_swap));
assert!(crate::api::request_changes_ui(&pane_focus_direction));
assert!(crate::api::request_changes_ui(&pane_resize));
+ assert!(crate::api::request_changes_ui(&agent_view));
}
#[test]
diff --git a/src/app/state.rs b/src/app/state.rs
index ee9c69be..f8395cbd 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1491,6 +1491,8 @@ pub struct AppState {
/// Ratio of sidebar height allocated to the workspaces section.
pub sidebar_section_split: f32,
pub agent_panel_sort: AgentPanelSort,
+ /// Transient session-wide projection override for the built-in Agents view.
+ pub agent_view_override: Option<crate::api::schema::AgentViewSetParams>,
pub sidebar_agents: crate::config::AgentsSidebarConfig,
pub sidebar_spaces: crate::config::SpacesSidebarConfig,
pub next_agent_state_change_seq: u64,
@@ -1868,6 +1870,7 @@ impl AppState {
sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact,
sidebar_section_split: 0.5,
agent_panel_sort: AgentPanelSort::Spaces,
+ agent_view_override: None,
sidebar_agents: crate::config::AgentsSidebarConfig::default(),
sidebar_spaces: crate::config::SpacesSidebarConfig::default(),
next_agent_state_change_seq: 0,
diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs
index a61a78ab..ca537bb8 100644
--- a/src/app/worktrees.rs
+++ b/src/app/worktrees.rs
@@ -1753,6 +1753,7 @@ mod tests {
enabled: true,
platforms: None,
build: Vec::new(),
+ startup: Vec::new(),
actions: Vec::new(),
events: vec![crate::api::schema::PluginManifestEventHook {
on: "worktree.created".into(),
diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs
index 32447d1b..5c051cc6 100644
--- a/src/cli/plugin.rs
+++ b/src/cli/plugin.rs
@@ -1199,6 +1199,7 @@ fn print_install_preview(
eprintln!(" commit: {commit}");
}
eprintln!(" actions: {}", plugin.actions.len());
+ eprintln!(" startup commands: {}", plugin.startup.len());
eprintln!(" events: {}", plugin.events.len());
eprintln!(" panes: {}", plugin.panes.len());
eprintln!(" link handlers: {}", plugin.link_handlers.len());
@@ -1214,6 +1215,9 @@ fn print_install_preview(
};
eprintln!(" build{}: {}", support, build.command.join(" "));
}
+ for startup in &plugin.startup {
+ eprintln!(" startup: {}", startup.command.join(" "));
+ }
for action in &plugin.actions {
eprintln!(" action {}: {}", action.id, action.command.join(" "));
}
@@ -1675,6 +1679,7 @@ mod tests {
enabled: true,
platforms: None,
build: vec![],
+ startup: vec![],
actions: vec![],
events: vec![],
panes: vec![],
diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs
index 34796a61..5cd1a1f5 100644
--- a/src/persist/plugin_registry.rs
+++ b/src/persist/plugin_registry.rs
@@ -123,6 +123,7 @@ mod tests {
enabled: true,
platforms: None,
build: vec![],
+ startup: vec![],
actions: vec![],
events: vec![],
panes: vec![],
@@ -207,6 +208,7 @@ mod tests {
enabled: true, // caller would pass stored enabled; fresh parse returns true
platforms: None,
build: vec![],
+ startup: vec![],
actions: vec![],
events: vec![],
panes: vec![],
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c5e8ca62..a2b8811a 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -4246,6 +4246,7 @@ pub fn run_server() -> io::Result<()> {
"herdr server started"
);
print_ready_message(&api::socket_path(), &client_socket_path());
+ server.app.run_plugin_startup_hooks();
server.run().await
});
@@ -4348,6 +4349,7 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
}
info!("handoff import server started");
print_ready_message(&api::socket_path(), &client_socket_path());
+ server.app.run_plugin_startup_hooks();
server.run().await
});
diff --git a/src/ui.rs b/src/ui.rs
index 6fcb28c3..6d5c02f1 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -76,12 +76,12 @@ pub(crate) use self::{
sidebar::{
agent_entry_gap, 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,
+ agent_panel_toggle_rect, all_agent_panel_entries, 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, AgentPanelEntry, WorkspaceListEntry,
},
};
pub(crate) use self::{
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index d1e6ac61..10b58af0 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -94,13 +94,12 @@ pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_hei
mobile_switcher_content_height(app).saturating_sub(viewport_height as usize)
}
-/// Doc-row height of the agents section (title + two rows per agent), or 0 when
-/// there are no agents so we don't show an empty header. The switcher leads with
-/// agents, so every section below it is offset by this.
+/// Doc-row height of the agents section. An active query keeps its title and an
+/// empty-state row visible even when no agents match.
fn mobile_agents_block_height(app: &AppState) -> usize {
let count = agent_panel_entries(app).len();
if count == 0 {
- 0
+ usize::from(app.agent_view_override.is_some()) * 2
} else {
1 + count * 2
}
@@ -136,27 +135,34 @@ pub(crate) fn mobile_switcher_target_at(
return None;
}
- let doc_row = app
+ let scroll = app
.mobile_switcher_scroll
- .saturating_add(row.saturating_sub(areas.viewport.y) as usize);
+ .min(mobile_switcher_max_scroll_for_height(
+ app,
+ areas.viewport.height,
+ ));
+ let doc_row = scroll.saturating_add(row.saturating_sub(areas.viewport.y) as usize);
let mut cursor = 0usize;
// Agents lead the switcher: the primary job is switching between running
// agents. Spaces/tabs/create actions follow for navigation and management.
- // The section is omitted entirely when there are no agents.
let agents = agent_panel_entries(app);
- if !agents.is_empty() {
+ if !agents.is_empty() || app.agent_view_override.is_some() {
cursor += 1; // agents title
- let agents_end = cursor + agents.len() * 2;
- if doc_row >= cursor && doc_row < agents_end {
- let idx = (doc_row - cursor) / 2;
- return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent {
- ws_idx: entry.ws_idx,
- tab_idx: entry.tab_idx,
- pane_id: entry.pane_id,
- });
+ if agents.is_empty() {
+ cursor += 1; // active-query empty state
+ } else {
+ let agents_end = cursor + agents.len() * 2;
+ if doc_row >= cursor && doc_row < agents_end {
+ let idx = (doc_row - cursor) / 2;
+ return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent {
+ ws_idx: entry.ws_idx,
+ tab_idx: entry.tab_idx,
+ pane_id: entry.pane_id,
+ });
+ }
+ cursor = agents_end;
}
- cursor = agents_end;
}
cursor += 1; // spaces title
@@ -492,22 +498,42 @@ fn render_mobile_switcher_content(
let mut doc_y = 0usize;
let entries = agent_panel_entries_from(app, terminal_runtimes);
- if !entries.is_empty() {
+ if !entries.is_empty() || app.agent_view_override.is_some() {
let focused_agent = app.active.and_then(|ws_idx| {
let ws = app.workspaces.get(ws_idx)?;
ws.focused_pane_id()
.map(|pane_id| (ws_idx, ws.active_tab, pane_id))
});
+ let title = app
+ .agent_view_override
+ .as_ref()
+ .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered")))
+ .unwrap_or_else(|| "agents".to_string());
render_section_title_at(
frame,
viewport,
content,
doc_y,
app.mobile_switcher_scroll,
- "agents",
+ &title,
p,
);
doc_y += 1;
+ if entries.is_empty() {
+ render_one_line_item(
+ frame,
+ viewport,
+ content,
+ doc_y,
+ app.mobile_switcher_scroll,
+ ratatui::style::Color::Reset,
+ Line::from(Span::styled(
+ " no matching agents",
+ Style::default().fg(p.overlay0).add_modifier(Modifier::DIM),
+ )),
+ );
+ doc_y += 1;
+ }
for entry in &entries {
let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| {
entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id
@@ -968,7 +994,7 @@ impl GlobalAgentCounts {
fn global_agent_counts(app: &AppState) -> GlobalAgentCounts {
let mut counts = GlobalAgentCounts::default();
- for entry in agent_panel_entries(app) {
+ for entry in crate::ui::all_agent_panel_entries(app) {
match (entry.state, entry.seen) {
(AgentState::Blocked, _) => counts.blocked += 1,
(AgentState::Idle, false) => counts.done += 1,
@@ -1140,6 +1166,7 @@ mod tests {
terminal_title: None,
terminal_title_stripped: None,
agent_label: agent_label.map(str::to_string),
+ agent_kind_label: agent_label.map(str::to_string),
agent: agent_label.and_then(crate::detect::parse_agent_label),
state: AgentState::Idle,
seen: true,
@@ -1150,6 +1177,40 @@ mod tests {
}
#[test]
+ fn global_agent_counts_ignore_active_agent_view_filter() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ crate::workspace::Workspace::test_new("blocked"),
+ crate::workspace::Workspace::test_new("working"),
+ ];
+ app.ensure_test_terminals();
+ for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] {
+ let pane_id = app.workspaces[ws_idx].tabs[0].root_pane;
+ let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.terminals.get_mut(&terminal_id).unwrap();
+ terminal.detected_agent = Some(crate::detect::Agent::Claude);
+ terminal.state = state;
+ }
+ app.agent_view_override = Some(crate::api::schema::AgentViewSetParams {
+ source: "example.views".to_string(),
+ label: None,
+ filter: Some(crate::api::schema::AgentViewFilter::Eq {
+ field: crate::api::schema::AgentViewField::Builtin(
+ crate::api::schema::AgentViewBuiltinField::Status,
+ ),
+ value: crate::api::schema::AgentViewValue::String("working".to_string()),
+ }),
+ sort: Vec::new(),
+ });
+
+ let counts = global_agent_counts(&app);
+ assert_eq!(counts.blocked, 1);
+ assert_eq!(counts.working, 1);
+ }
+
+ #[test]
fn agent_summary_leads_with_attention_states_in_priority_order() {
let counts = GlobalAgentCounts {
blocked: 2,
@@ -1252,6 +1313,7 @@ mod tests {
assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7);
let viewport = mobile_switcher_areas(&app).viewport;
+ app.mobile_switcher_scroll = 100;
let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1);
assert!(matches!(
agent_hit,
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index f89b6e6d..0ecfb652 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -30,6 +30,7 @@ pub(crate) struct AgentPanelEntry {
pub terminal_title: Option<String>,
pub terminal_title_stripped: Option<String>,
pub agent_label: Option<String>,
+ pub agent_kind_label: Option<String>,
pub agent: Option<crate::detect::Agent>,
pub state: AgentState,
pub seen: bool,
@@ -85,12 +86,15 @@ fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str {
}
pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect {
+ agent_panel_header_label_rect(area, agent_panel_sort_label(sort))
+}
+
+fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect {
if area.width == 0 || area.height < 2 {
return Rect::default();
}
- let label = agent_panel_sort_label(sort);
- let width = display_width_u16(label);
+ let width = display_width_u16(label).min(area.width);
Rect::new(
area.x + area.width.saturating_sub(width),
area.y + 1,
@@ -99,10 +103,20 @@ pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect
)
}
+fn active_agent_view_label(app: &AppState) -> Option<&str> {
+ app.agent_view_override
+ .as_ref()
+ .map(|view| view.label.as_deref().unwrap_or("filtered"))
+}
+
pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
agent_panel_entries_with_runtimes(app, None)
}
+pub(crate) fn all_agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
+ collect_agent_panel_entries_with_runtimes(app, None)
+}
+
pub(crate) fn agent_panel_entries_from(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -114,6 +128,15 @@ fn agent_panel_entries_with_runtimes(
app: &AppState,
terminal_runtimes: Option<&TerminalRuntimeRegistry>,
) -> Vec<AgentPanelEntry> {
+ let mut entries = collect_agent_panel_entries_with_runtimes(app, terminal_runtimes);
+ crate::app::agent_view::apply_agent_view(app, &mut entries);
+ entries
+}
+
+fn collect_agent_panel_entries_with_runtimes(
+ app: &AppState,
+ terminal_runtimes: Option<&TerminalRuntimeRegistry>,
+) -> Vec<AgentPanelEntry> {
let empty_runtimes;
let terminal_runtimes = match terminal_runtimes {
Some(terminal_runtimes) => terminal_runtimes,
@@ -123,8 +146,7 @@ fn agent_panel_entries_with_runtimes(
}
};
- let mut entries: Vec<_> = app
- .workspaces
+ app.workspaces
.iter()
.enumerate()
.flat_map(|(ws_idx, ws)| {
@@ -148,6 +170,7 @@ fn agent_panel_entries_with_runtimes(
terminal_title: detail.terminal_title,
terminal_title_stripped: detail.terminal_title_stripped,
agent_label: Some(detail.agent_label),
+ agent_kind_label: detail.agent_kind_label,
agent: detail.agent,
state: detail.state,
seen: detail.seen,
@@ -157,18 +180,7 @@ fn agent_panel_entries_with_runtimes(
}
})
})
- .collect();
-
- if matches!(app.agent_panel_sort, AgentPanelSort::Priority) {
- entries.sort_by_key(|entry| {
- (
- std::cmp::Reverse(workspace_attention_priority(entry.state, entry.seen)),
- std::cmp::Reverse(entry.last_agent_state_change_seq),
- )
- });
- }
-
- entries
+ .collect()
}
pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str {
@@ -800,9 +812,14 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area:
if let Some(divider_y) = divider_y {
let buf = frame.buffer_mut();
+ let divider_color = if app.agent_view_override.is_some() {
+ p.accent
+ } else {
+ p.surface_dim
+ };
for x in ws_area.x..ws_area.x + ws_area.width {
buf[(x, divider_y)].set_symbol("─");
- buf[(x, divider_y)].set_style(Style::default().fg(p.surface_dim));
+ buf[(x, divider_y)].set_style(Style::default().fg(divider_color));
}
}
@@ -1315,12 +1332,19 @@ fn render_agent_detail(
)])),
Rect::new(area.x, area.y + 1, area.width, 1),
);
- let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_sort);
+ let control_label = active_agent_view_label(app)
+ .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort));
+ let toggle_rect = agent_panel_header_label_rect(area, control_label);
if toggle_rect != Rect::default() {
+ let color = if app.agent_view_override.is_some() {
+ p.accent
+ } else {
+ p.overlay0
+ };
frame.render_widget(
Paragraph::new(Span::styled(
- agent_panel_sort_label(app.agent_panel_sort),
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ control_label,
+ Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Right),
toggle_rect,
@@ -1334,10 +1358,19 @@ fn render_agent_detail(
if body == Rect::default() {
return;
}
+ if details.is_empty() && app.agent_view_override.is_some() {
+ frame.render_widget(
+ Paragraph::new(" no matching agents")
+ .style(Style::default().fg(p.overlay0).add_modifier(Modifier::DIM)),
+ Rect::new(body.x, body.y, body.width, 1),
+ );
+ return;
+ }
+ let scroll = app.agent_panel_scroll.min(metrics.max_offset_from_bottom);
let mut row_y = body.y;
let body_bottom = body.y + body.height;
- for (index, detail) in details.iter().enumerate().skip(app.agent_panel_scroll) {
+ for (index, detail) in details.iter().enumerate().skip(scroll) {
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);
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
index 22e13e86..c578e13f 100644
--- a/src/ui/sidebar/tokens.rs
+++ b/src/ui/sidebar/tokens.rs
@@ -168,6 +168,7 @@ mod tests {
terminal_title: None,
terminal_title_stripped: None,
agent_label: Some("pi".into()),
+ agent_kind_label: Some("pi".into()),
agent: Some(crate::detect::Agent::Pi),
state: AgentState::Working,
seen: true,
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index a3eed860..79b5a021 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -16,6 +16,7 @@ pub struct PaneDetail {
pub terminal_title: Option<String>,
pub terminal_title_stripped: Option<String>,
pub agent_label: String,
+ pub agent_kind_label: Option<String>,
pub agent: Option<Agent>,
pub state: AgentState,
pub seen: bool,
@@ -45,10 +46,11 @@ impl Tab {
.filter_map(|id| {
let pane = self.panes.get(id)?;
let terminal = terminals.get(&pane.attached_terminal_id)?;
+ let agent_kind_label = terminal.effective_agent_label().map(str::to_string);
let fallback_agent_label = terminal
.agent_name
.as_deref()
- .or_else(|| terminal.effective_agent_label())?
+ .or(agent_kind_label.as_deref())?
.to_string();
let agent_label = terminal
.effective_display_agent()
@@ -65,6 +67,7 @@ impl Tab {
terminal_title: terminal.terminal_title.clone(),
terminal_title_stripped: terminal.terminal_title_stripped(),
agent_label,
+ agent_kind_label,
agent: terminal.effective_known_agent(),
state: terminal.state,
seen: pane.seen,