aboutsummaryrefslogtreecommitdiff
path: root/src/app
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 /src/app
parent3f80947678a9723434aa7fddfe23750b5393e24c (diff)
feat: add plugin-driven agent views and startup hooks
Diffstat (limited to 'src/app')
-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
13 files changed, 1020 insertions, 15 deletions
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(),