diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-20 00:54:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-20 00:55:36 +0300 |
| commit | d30ab1b54f6b6be95a0cb47ddc38da1d5e088700 (patch) | |
| tree | eb2002b408c856d9fcd7a573afe73b917f22cd97 /src | |
| parent | 3f80947678a9723434aa7fddfe23750b5393e24c (diff) | |
feat: add plugin-driven agent views and startup hooks
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 4 | ||||
| -rw-r--r-- | src/api/schema.rs | 4 | ||||
| -rw-r--r-- | src/api/schema/agents.rs | 114 | ||||
| -rw-r--r-- | src/api/schema/plugins.rs | 9 | ||||
| -rw-r--r-- | src/api/schema/response.rs | 7 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 44 | ||||
| -rw-r--r-- | src/api/server.rs | 2 | ||||
| -rw-r--r-- | src/app/agent_view.rs | 573 | ||||
| -rw-r--r-- | src/app/api.rs | 5 | ||||
| -rw-r--r-- | src/app/api/agent_view.rs | 185 | ||||
| -rw-r--r-- | src/app/api/plugins/manifest.rs | 29 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 96 | ||||
| -rw-r--r-- | src/app/api/plugins/runtime.rs | 35 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 1 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 48 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 49 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 9 | ||||
| -rw-r--r-- | src/app/state.rs | 3 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 1 | ||||
| -rw-r--r-- | src/cli/plugin.rs | 5 | ||||
| -rw-r--r-- | src/persist/plugin_registry.rs | 2 | ||||
| -rw-r--r-- | src/server/headless.rs | 2 | ||||
| -rw-r--r-- | src/ui.rs | 12 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 102 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 75 | ||||
| -rw-r--r-- | src/ui/sidebar/tokens.rs | 1 | ||||
| -rw-r--r-- | src/workspace/aggregate.rs | 5 |
28 files changed, 1360 insertions, 63 deletions
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 }); @@ -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, |
