From 5cfe5e5e730246948b0c9428ad876b6938590aad Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 13 Jul 2026 03:26:03 +0300 Subject: feat: add configurable sidebar metadata tokens --- src/api/mod.rs | 1 + src/api/schema.rs | 2 + src/api/schema/agents.rs | 9 +- src/api/schema/common.rs | 22 + src/api/schema/events.rs | 22 +- src/api/schema/panes.rs | 19 +- src/api/schema/tests.rs | 10 +- src/api/schema/workspaces.rs | 16 + src/api/server.rs | 5 +- src/api/subscriptions.rs | 84 ++-- src/api/wait.rs | 1 - src/app/actions.rs | 89 +++-- src/app/agents.rs | 4 +- src/app/api.rs | 25 +- src/app/api/panes.rs | 271 ++++++++++--- src/app/api/plugins/context.rs | 3 +- src/app/api/plugins/mod.rs | 1 - src/app/api/workspaces.rs | 157 +++++++- src/app/api_helpers.rs | 119 +++++- src/app/creation.rs | 5 +- src/app/input/sidebar.rs | 66 ++- src/app/mod.rs | 65 +++ src/app/runtime.rs | 43 +- src/app/state.rs | 4 + src/app/terminal_titles.rs | 148 +++++++ src/cli.rs | 12 +- src/cli/pane.rs | 63 ++- src/cli/spec.rs | 19 +- src/cli/workspace.rs | 91 ++++- src/config.rs | 5 + src/config/model.rs | 7 +- src/config/sidebar.rs | 400 +++++++++++++++++++ src/events.rs | 3 - src/handoff_runtime.rs | 2 + src/main.rs | 15 + src/metadata_tokens.rs | 259 ++++++++++++ src/pane.rs | 13 +- src/pane/osc.rs | 46 ++- src/pane/terminal.rs | 18 + src/persist/restore.rs | 2 + src/persist/snapshot.rs | 1 - src/server/headless.rs | 122 +++++- src/terminal/metadata.rs | 439 ++++++-------------- src/terminal/mod.rs | 2 + src/terminal/runtime.rs | 4 + src/terminal/state.rs | 229 +++-------- src/terminal/title.rs | 64 +++ src/ui.rs | 15 +- src/ui/mobile.rs | 10 +- src/ui/navigator.rs | 3 - src/ui/sidebar.rs | 888 ++++++++++++++++++++++++++++++----------- src/ui/sidebar/tokens.rs | 285 +++++++++++++ src/workspace.rs | 8 + src/workspace/aggregate.rs | 13 +- 54 files changed, 3231 insertions(+), 998 deletions(-) create mode 100644 src/app/terminal_titles.rs create mode 100644 src/config/sidebar.rs create mode 100644 src/metadata_tokens.rs create mode 100644 src/terminal/title.rs create mode 100644 src/ui/sidebar/tokens.rs (limited to 'src') diff --git a/src/api/mod.rs b/src/api/mod.rs index b285eb3d..6f8fa9df 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,6 +28,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceMove(_) + | Method::WorkspaceReportMetadata(_) | Method::WorkspaceClose(_) | Method::WorktreeCreate(_) | Method::WorktreeOpen(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 1a51dfbc..4fb3d841 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -75,6 +75,8 @@ pub enum Method { WorkspaceRename(WorkspaceRenameParams), #[serde(rename = "workspace.move")] WorkspaceMove(WorkspaceMoveParams), + #[serde(rename = "workspace.report_metadata")] + WorkspaceReportMetadata(WorkspaceReportMetadataParams), #[serde(rename = "workspace.close")] WorkspaceClose(WorkspaceTarget), #[serde(rename = "worktree.list")] diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index e5f319e0..969383c4 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -57,14 +57,19 @@ pub struct AgentInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title_stripped: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option, pub agent_status: AgentStatus, #[serde(default, skip_serializing_if = "super::is_false")] pub screen_detection_skipped: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub state_labels: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_values_schema")] + pub tokens: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, pub workspace_id: String, diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index 3d8376ed..e22fa1b3 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -1,5 +1,27 @@ use serde::{Deserialize, Serialize}; +pub(super) fn metadata_token_patch_schema( + _generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + schemars::json_schema!({ + "type": "object", + "maxProperties": 16, + "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,32}$" }, + "additionalProperties": { "type": ["string", "null"] } + }) +} + +pub(super) fn metadata_token_values_schema( + _generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + schemars::json_schema!({ + "type": "object", + "maxProperties": 32, + "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,32}$" }, + "additionalProperties": { "type": "string" } + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] pub struct EmptyParams {} diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 41b9907d..25ccc8ec 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -20,6 +20,8 @@ pub enum Subscription { WorkspaceCreated {}, #[serde(rename = "workspace.updated")] WorkspaceUpdated {}, + #[serde(rename = "workspace.metadata_updated")] + WorkspaceMetadataUpdated {}, #[serde(rename = "workspace.renamed")] WorkspaceRenamed {}, #[serde(rename = "workspace.moved")] @@ -48,6 +50,8 @@ pub enum Subscription { PaneCreated {}, #[serde(rename = "pane.closed")] PaneClosed {}, + #[serde(rename = "pane.updated")] + PaneUpdated {}, #[serde(rename = "pane.focused")] PaneFocused {}, #[serde(rename = "pane.moved")] @@ -188,6 +192,7 @@ pub enum EventMatch { pub enum EventKind { WorkspaceCreated, WorkspaceUpdated, + WorkspaceMetadataUpdated, WorkspaceClosed, WorkspaceRenamed, WorkspaceMoved, @@ -202,6 +207,7 @@ pub enum EventKind { TabFocused, PaneCreated, PaneClosed, + PaneUpdated, PaneFocused, PaneMoved, PaneOutputChanged, @@ -216,6 +222,7 @@ impl EventKind { match self { EventKind::WorkspaceCreated => "workspace.created", EventKind::WorkspaceUpdated => "workspace.updated", + EventKind::WorkspaceMetadataUpdated => "workspace.metadata_updated", EventKind::WorkspaceClosed => "workspace.closed", EventKind::WorkspaceRenamed => "workspace.renamed", EventKind::WorkspaceMoved => "workspace.moved", @@ -230,6 +237,7 @@ impl EventKind { EventKind::TabFocused => "tab.focused", EventKind::PaneCreated => "pane.created", EventKind::PaneClosed => "pane.closed", + EventKind::PaneUpdated => "pane.updated", EventKind::PaneFocused => "pane.focused", EventKind::PaneMoved => "pane.moved", EventKind::PaneOutputChanged => "pane.output_changed", @@ -245,6 +253,7 @@ impl EventKind { pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ EventKind::WorkspaceCreated, EventKind::WorkspaceUpdated, + EventKind::WorkspaceMetadataUpdated, EventKind::WorkspaceClosed, EventKind::WorkspaceRenamed, EventKind::WorkspaceMoved, @@ -259,6 +268,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ EventKind::TabFocused, EventKind::PaneCreated, EventKind::PaneClosed, + EventKind::PaneUpdated, EventKind::PaneFocused, EventKind::PaneMoved, EventKind::PaneOutputChanged, @@ -336,6 +346,8 @@ mod known_event_name_tests { let names = plugin_hook_event_names(); assert!(!names.contains(&"pane.output_changed")); assert!(!names.contains(&"layout.updated")); + assert!(!names.contains(&"workspace.metadata_updated")); + assert!(!names.contains(&"pane.updated")); assert!(names.contains(&"pane.moved")); } } @@ -385,8 +397,6 @@ pub struct PaneAgentStatusChangedEvent { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option, @@ -410,6 +420,9 @@ pub enum EventData { WorkspaceUpdated { workspace: WorkspaceInfo, }, + WorkspaceMetadataUpdated { + workspace: WorkspaceInfo, + }, WorkspaceClosed { workspace_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -472,6 +485,9 @@ pub enum EventData { pane_id: String, workspace_id: String, }, + PaneUpdated { + pane: PaneInfo, + }, PaneFocused { pane_id: String, workspace_id: String, @@ -515,8 +531,6 @@ pub enum EventData { title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] display_agent: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - custom_status: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] state_labels: HashMap, }, diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 42a640ef..2e37e734 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -266,8 +266,6 @@ pub struct PaneReportAgentParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_id: Option, @@ -302,21 +300,21 @@ pub struct PaneReportMetadataParams { pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub state_labels: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_patch_schema")] + pub tokens: HashMap>, #[serde(default)] pub clear_title: bool, #[serde(default)] pub clear_display_agent: bool, #[serde(default)] - pub clear_custom_status: bool, - #[serde(default)] pub clear_state_labels: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 86_400_000))] pub ttl_ms: Option, } @@ -356,12 +354,17 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title_stripped: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option, pub agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub state_labels: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_values_schema")] + pub tokens: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 5f603a18..adc7e61c 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -596,6 +596,7 @@ fn worktree_request_and_response_round_trip() { tab_count: 1, active_tab_id: "w_1:1".into(), agent_status: AgentStatus::Unknown, + tokens: HashMap::new(), worktree: Some(WorkspaceWorktreeInfo { repo_key: "/repo/herdr/.git".into(), repo_name: "herdr".into(), @@ -624,10 +625,12 @@ fn worktree_request_and_response_round_trip() { label: None, agent: None, title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status: AgentStatus::Unknown, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, @@ -679,6 +682,7 @@ fn worktree_lifecycle_events_round_trip() { tab_count: 1, active_tab_id: "w_2:1".into(), agent_status: AgentStatus::Unknown, + tokens: HashMap::new(), worktree: Some(WorkspaceWorktreeInfo { repo_key: "/repo/herdr/.git".into(), repo_name: "herdr".into(), @@ -1032,10 +1036,12 @@ fn create_response_round_trips_with_root_pane() { label: None, agent: None, title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status: AgentStatus::Unknown, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index 6934539c..a6177982 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -28,6 +28,19 @@ pub struct WorkspaceMoveParams { pub insert_index: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct WorkspaceReportMetadataParams { + pub workspace_id: String, + pub source: String, + #[schemars(schema_with = "super::common::metadata_token_patch_schema")] + pub tokens: HashMap>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 86_400_000))] + pub ttl_ms: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct WorkspaceInfo { pub workspace_id: String, @@ -38,6 +51,9 @@ pub struct WorkspaceInfo { pub tab_count: usize, pub active_tab_id: String, pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_values_schema")] + pub tokens: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, } diff --git a/src/api/server.rs b/src/api/server.rs index 692e9388..9cef5ec1 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -322,6 +322,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", Method::WorkspaceMove(_) => "workspace.move", + Method::WorkspaceReportMetadata(_) => "workspace.report_metadata", Method::WorkspaceClose(_) => "workspace.close", Method::WorktreeList(_) => "worktree.list", Method::WorktreeCreate(_) => "worktree.create", @@ -811,10 +812,12 @@ mod tests { label: None, agent: Some("pi".into()), title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index b3a369c2..531fb7bb 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -66,7 +66,6 @@ pub(super) struct ActiveScrollChangedSubscription { struct PanePresentationSnapshot { title: Option, display_agent: Option, - custom_status: Option, state_labels: std::collections::HashMap, } @@ -75,7 +74,6 @@ impl PanePresentationSnapshot { Self { title: pane.title.clone(), display_agent: pane.display_agent.clone(), - custom_status: pane.custom_status.clone(), state_labels: pane.state_labels.clone(), } } @@ -83,13 +81,11 @@ impl PanePresentationSnapshot { fn from_event( title: &Option, display_agent: &Option, - custom_status: &Option, state_labels: &std::collections::HashMap, ) -> Self { Self { title: title.clone(), display_agent: display_agent.clone(), - custom_status: custom_status.clone(), state_labels: state_labels.clone(), } } @@ -124,6 +120,10 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceUpdated, last_sequence: 0, })), + Subscription::WorkspaceMetadataUpdated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceMetadataUpdated, + last_sequence: 0, + })), Subscription::WorkspaceRenamed {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::WorkspaceRenamed, last_sequence: 0, @@ -180,6 +180,10 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::PaneClosed, last_sequence: 0, })), + Subscription::PaneUpdated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneUpdated, + last_sequence: 0, + })), Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::PaneFocused, last_sequence: 0, @@ -261,7 +265,6 @@ impl ActiveSubscription { agent: probe.agent, title: probe.title, display_agent: probe.display_agent, - custom_status: probe.custom_status, state_labels: probe.state_labels, }); @@ -373,7 +376,6 @@ impl ActiveAgentStatusChangedSubscription { agent, title, display_agent, - custom_status, state_labels, } = event.data else { @@ -387,12 +389,8 @@ impl ActiveAgentStatusChangedSubscription { } saw_status_event = true; - let current_presentation = PanePresentationSnapshot::from_event( - &title, - &display_agent, - &custom_status, - &state_labels, - ); + let current_presentation = + PanePresentationSnapshot::from_event(&title, &display_agent, &state_labels); self.last_status = Some(agent_status); self.last_presentation = Some(current_presentation); if self @@ -412,7 +410,6 @@ impl ActiveAgentStatusChangedSubscription { agent, title, display_agent, - custom_status, state_labels, }), }); @@ -479,7 +476,6 @@ impl ActiveAgentStatusChangedSubscription { agent: pane.agent, title: pane.title, display_agent: pane.display_agent, - custom_status: pane.custom_status, state_labels: pane.state_labels, }), }) @@ -613,7 +609,7 @@ mod tests { use super::*; use crate::api::schema::{AgentStatus, EventData, EventEnvelope, EventKind, PaneInfo}; - fn status_event(custom_status: Option<&str>) -> EventEnvelope { + fn presentation_event(title: Option<&str>) -> EventEnvelope { EventEnvelope { event: EventKind::PaneAgentStatusChanged, data: EventData::PaneAgentStatusChanged { @@ -621,9 +617,8 @@ mod tests { workspace_id: "workspace_1".into(), agent_status: AgentStatus::Working, agent: Some("pi".into()), - title: None, + title: title.map(str::to_string), display_agent: None, - custom_status: custom_status.map(str::to_string), state_labels: HashMap::new(), }, } @@ -641,16 +636,40 @@ mod tests { label: None, agent: None, title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status: AgentStatus::Unknown, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll, revision: 0, } } + #[test] + fn workspace_metadata_subscription_uses_dedicated_event_kind() { + let event_hub = EventHub::default(); + let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel(); + let subscription = ActiveSubscription::new( + Subscription::WorkspaceMetadataUpdated {}, + "test", + 0, + &api_tx, + &event_hub, + ) + .expect("workspace metadata subscription"); + + assert!(matches!( + subscription, + ActiveSubscription::Event(ActiveEventSubscription { + event_kind: EventKind::WorkspaceMetadataUpdated, + .. + }) + )); + } + #[test] fn scroll_subscription_emits_when_scroll_snapshot_changes() { let at_bottom = PaneScrollInfo { @@ -695,7 +714,6 @@ mod tests { last_presentation: Some(PanePresentationSnapshot { title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), }), last_sequence: event_hub.current_sequence(), @@ -703,8 +721,8 @@ mod tests { request_prefix: "test".into(), }; - event_hub.push(status_event(Some("short lived"))); - event_hub.push(status_event(None)); + event_hub.push(presentation_event(Some("short lived"))); + event_hub.push(presentation_event(None)); let set_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -712,7 +730,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else { panic!("wrong event data"); }; - assert_eq!(set_data.custom_status.as_deref(), Some("short lived")); + assert_eq!(set_data.title.as_deref(), Some("short lived")); let expiry_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -720,7 +738,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else { panic!("wrong event data"); }; - assert_eq!(expiry_data.custom_status, None); + assert_eq!(expiry_data.title, None); } #[test] @@ -733,7 +751,6 @@ mod tests { last_presentation: Some(PanePresentationSnapshot { title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), }), last_sequence: event_hub.current_sequence(), @@ -744,14 +761,13 @@ mod tests { agent: Some("pi".into()), title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), }), request_prefix: "test".into(), }; - event_hub.push(status_event(Some("short lived"))); - event_hub.push(status_event(None)); + event_hub.push(presentation_event(Some("short lived"))); + event_hub.push(presentation_event(None)); let set_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -759,7 +775,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else { panic!("wrong event data"); }; - assert_eq!(set_data.custom_status.as_deref(), Some("short lived")); + assert_eq!(set_data.title.as_deref(), Some("short lived")); let expiry_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -767,7 +783,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else { panic!("wrong event data"); }; - assert_eq!(expiry_data.custom_status, None); + assert_eq!(expiry_data.title, None); } #[test] @@ -778,9 +794,8 @@ mod tests { status_filter: Some(AgentStatus::Working), last_status: Some(AgentStatus::Working), last_presentation: Some(PanePresentationSnapshot { - title: None, + title: Some("short lived".into()), display_agent: None, - custom_status: Some("short lived".into()), state_labels: HashMap::new(), }), last_sequence: event_hub.current_sequence(), @@ -789,15 +804,14 @@ mod tests { workspace_id: "workspace_1".into(), agent_status: AgentStatus::Working, agent: Some("pi".into()), - title: None, + title: Some("short lived".into()), display_agent: None, - custom_status: Some("short lived".into()), state_labels: HashMap::new(), }), request_prefix: "test".into(), }; - event_hub.push(status_event(Some("short lived"))); + event_hub.push(presentation_event(Some("short lived"))); let event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -805,7 +819,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(data) = event.data else { panic!("wrong event data"); }; - assert_eq!(data.custom_status.as_deref(), Some("short lived")); + assert_eq!(data.title.as_deref(), Some("short lived")); assert!(subscription.initial_event.is_none()); } } diff --git a/src/api/wait.rs b/src/api/wait.rs index e70472c3..17ffb992 100644 --- a/src/api/wait.rs +++ b/src/api/wait.rs @@ -231,7 +231,6 @@ fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String { agent: data.agent, title: data.title, display_agent: data.display_agent, - custom_status: data.custom_status, state_labels: data.state_labels, }, }, diff --git a/src/app/actions.rs b/src/app/actions.rs index 5c263e7e..7cf43e79 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -537,15 +537,13 @@ impl AppState { .and_then(|terminal| terminal.agent_name.as_deref()) .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) }); - let custom_status = terminal.and_then(|terminal| terminal.effective_custom_status()); let state = terminal .map(|terminal| terminal.state) .unwrap_or(AgentState::Unknown); let status_label = terminal .map(|terminal| terminal.effective_presentation().state_labels) .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); - let status = custom_status - .or(status_label) + let status = status_label .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); let meta = match (agent_label, status.as_deref()) { (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), @@ -893,6 +891,16 @@ impl AppState { self.terminals .values() .filter_map(|terminal| terminal.next_agent_metadata_expiry()) + .chain( + self.terminals + .values() + .filter_map(|terminal| terminal.metadata_tokens.next_expiry()), + ) + .chain( + self.workspaces + .iter() + .filter_map(|workspace| workspace.metadata_tokens.next_expiry()), + ) .min() } @@ -946,6 +954,48 @@ impl AppState { .collect() } + pub(crate) fn expire_metadata_tokens( + &mut self, + now: std::time::Instant, + ) -> (Vec<(usize, PaneId)>, Vec) { + let pane_terminals = self + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + workspace.tabs.iter().flat_map(move |tab| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| { + workspace + .pane_state(pane_id) + .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone())) + }) + }) + }) + .collect::>(); + let changed_panes = pane_terminals + .into_iter() + .filter_map(|(ws_idx, pane_id, terminal_id)| { + let terminal = self.terminals.get_mut(&terminal_id)?; + terminal.metadata_tokens.expire_at(now).then(|| { + terminal.revision = terminal.revision.saturating_add(1); + (ws_idx, pane_id) + }) + }) + .collect(); + let changed_workspaces = self + .workspaces + .iter_mut() + .enumerate() + .filter_map(|(ws_idx, workspace)| { + workspace.metadata_tokens.expire_at(now).then_some(ws_idx) + }) + .collect(); + (changed_panes, changed_workspaces) + } + pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(active_ws_idx) = self.active else { return false; @@ -1341,21 +1391,12 @@ impl AppState { self.view.sidebar_rect, self.sidebar_section_split, ); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let visible = metrics.viewport_rows; - if visible == 0 { - return; - } - - if idx < self.agent_panel_scroll { - self.agent_panel_scroll = idx; - } else if idx >= self.agent_panel_scroll.saturating_add(visible) { - self.agent_panel_scroll = idx.saturating_add(1).saturating_sub(visible); - } - - let max_scroll = - crate::ui::agent_panel_scroll_metrics(self, detail_area).max_offset_from_bottom; - self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll); + self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( + self, + detail_area, + self.agent_panel_scroll, + idx, + ); } pub(crate) fn terminal_ids_for_workspace( @@ -2559,7 +2600,6 @@ impl AppState { agent_label, state, message, - custom_status, seq, session_ref, } => { @@ -2576,7 +2616,6 @@ impl AppState { agent_label, state, message, - custom_status, session_ref, seq, ) @@ -2611,11 +2650,9 @@ impl AppState { applies_to_source, title, display_agent, - custom_status, state_labels, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, seq, ttl, @@ -2627,11 +2664,9 @@ impl AppState { applies_to_source, title, display_agent, - custom_status, state_labels, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, ttl, seq, @@ -4578,7 +4613,6 @@ mod tests { agent_label: "hermes".into(), state: AgentState::Blocked, message: None, - custom_status: None, seq: None, session_ref: None, }); @@ -4617,7 +4651,6 @@ mod tests { agent_label: "codex".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(1), session_ref: None, }); @@ -4666,7 +4699,6 @@ mod tests { agent_label: "claude".into(), state: AgentState::Blocked, message: None, - custom_status: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"), }); @@ -4749,7 +4781,6 @@ mod tests { agent_label: "devin".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"), }); @@ -4774,7 +4805,6 @@ mod tests { agent_label: "pi".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(20), session_ref: crate::agent_resume::AgentSessionRef::path(first_session), }); @@ -4787,7 +4817,6 @@ mod tests { agent_label: "pi".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(21), session_ref: crate::agent_resume::AgentSessionRef::path(second_session), }); diff --git a/src/app/agents.rs b/src/app/agents.rs index bec375b9..83f97bd4 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -422,11 +422,13 @@ impl App { name: terminal.agent_name.clone(), agent: pane.agent, title: pane.title, + terminal_title: pane.terminal_title, + terminal_title_stripped: pane.terminal_title_stripped, display_agent: pane.display_agent, agent_status: pane.agent_status, screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(), - custom_status: pane.custom_status, state_labels: pane.state_labels, + tokens: pane.tokens, agent_session: pane.agent_session, workspace_id: pane.workspace_id, tab_id: pane.tab_id, diff --git a/src/app/api.rs b/src/app/api.rs index 9ebbbe56..c013d271 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -579,7 +579,6 @@ impl App { agent: update.agent_label.clone(), title: presentation.title, display_agent: presentation.display_agent, - custom_status: presentation.custom_status, state_labels: presentation.state_labels, }, }); @@ -745,6 +744,26 @@ impl App { self.event_hub.push(event); } + pub(crate) fn emit_pane_updated(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { + if let Some(pane) = self.pane_info(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneUpdated, + data: crate::api::schema::EventData::PaneUpdated { pane }, + }); + } + } + + pub(crate) fn emit_workspace_token_updated(&mut self, ws_idx: usize) { + // Token updates bypass plugin hooks so a hook cannot refresh its own + // token and recursively trigger workspace.updated. + self.event_hub.push(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceMetadataUpdated, + data: crate::api::schema::EventData::WorkspaceMetadataUpdated { + workspace: self.workspace_info(ws_idx), + }, + }); + } + pub(crate) fn sync_focus_events(&mut self) { let current_focus = self.state.active.and_then(|idx| { self.state @@ -816,6 +835,7 @@ impl App { &mut self, request: crate::api::schema::Request, ) -> String { + self.sync_terminal_titles(); use crate::api::schema::{ ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse, }; @@ -908,6 +928,9 @@ impl App { Method::WorkspaceMove(params) => { return self.handle_workspace_move(request.id, params); } + Method::WorkspaceReportMetadata(params) => { + return self.handle_workspace_report_metadata(request.id, params); + } Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target) } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 58dbeb19..17569682 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,15 +20,14 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, - normalize_reported_agent_label, + detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, + normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, + MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; +#[cfg(test)] +use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; use super::responses::{encode_error, encode_success}; -const METADATA_SOURCE_MAX_CHARS: usize = 80; -const METADATA_TTL_MIN_MS: u64 = 1; -const METADATA_TTL_MAX_MS: u64 = 86_400_000; - impl App { pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() { @@ -1237,7 +1236,6 @@ impl App { agent_label, state: detect_state_from_api(params.state), message: params.message, - custom_status: normalize_custom_status(params.custom_status), seq: params.seq, }); @@ -1279,7 +1277,7 @@ impl App { id: String, params: PaneReportMetadataParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let agent_label = match params.agent.as_deref() { @@ -1295,15 +1293,21 @@ impl App { }; let raw_title_set = params.title.is_some(); let raw_display_agent_set = params.display_agent.is_some(); - let raw_custom_status_set = params.custom_status.is_some(); let raw_state_labels_set = !params.state_labels.is_empty(); + let tokens = if params.tokens.is_empty() { + None + } else { + match normalize_metadata_tokens(params.tokens) { + Ok(tokens) => Some(tokens), + Err(message) => return encode_error(id, "invalid_metadata_token", message), + } + }; let ttl = match normalize_metadata_ttl(params.ttl_ms) { Ok(ttl) => ttl, Err(message) => return encode_error(id, "invalid_metadata_ttl", message), }; let title = normalize_presentation_text(params.title); let display_agent = normalize_presentation_text(params.display_agent); - let custom_status = normalize_custom_status(params.custom_status); let applies_to_source = match params.applies_to_source { Some(applies_to_source) => match normalize_metadata_source(applies_to_source) { Ok(applies_to_source) => Some(applies_to_source), @@ -1323,7 +1327,6 @@ impl App { }; if raw_title_set && params.clear_title || raw_display_agent_set && params.clear_display_agent - || raw_custom_status_set && params.clear_custom_status || raw_state_labels_set && params.clear_state_labels { return encode_error( @@ -1334,11 +1337,10 @@ impl App { } if title.is_none() && display_agent.is_none() - && custom_status.is_none() && state_labels.is_empty() + && tokens.is_none() && !params.clear_title && !params.clear_display_agent - && !params.clear_custom_status && !params.clear_state_labels { return encode_error( @@ -1347,23 +1349,85 @@ impl App { "missing metadata field to set or clear", ); } - self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { - pane_id, - source, - agent_label, - applies_to_source, - title, - display_agent, - custom_status, - state_labels, - clear_title: params.clear_title, - clear_display_agent: params.clear_display_agent, - clear_custom_status: params.clear_custom_status, - clear_state_labels: params.clear_state_labels, - seq: params.seq, - ttl, + let presentation_requested = title.is_some() + || display_agent.is_some() + || !state_labels.is_empty() + || params.clear_title + || params.clear_display_agent + || params.clear_state_labels; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.pane_state(pane_id)) + .map(|pane| pane.attached_terminal_id.clone()) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) { + return encode_success(id, ResponseResult::Ok {}); + } + if let Some(tokens) = tokens.as_ref() { + if terminal.metadata_tokens.key_count_after_patch(tokens) + > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + { + return encode_error( + id, + "metadata_token_limit", + format!( + "pane metadata may contain at most {MAX_METADATA_TOKEN_KEYS_PER_RESOURCE} tokens" + ), + ); + } + } + match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) { + Ok(true) => {} + Ok(false) => return encode_success(id, ResponseResult::Ok {}), + Err(()) => { + return encode_error( + id, + "metadata_sequence_source_limit", + format!( + "pane metadata may track at most {} sequenced sources", + crate::metadata_tokens::MAX_SEQUENCE_SOURCES + ), + ); + } + } + let token_changed = tokens.is_some_and(|tokens| { + let changed = terminal + .metadata_tokens + .patch(tokens, ttl, std::time::Instant::now()); + if changed { + terminal.revision = terminal.revision.saturating_add(1); + } + changed }); + if presentation_requested { + self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { + pane_id, + source, + agent_label, + applies_to_source, + title, + display_agent, + state_labels, + clear_title: params.clear_title, + clear_display_agent: params.clear_display_agent, + clear_state_labels: params.clear_state_labels, + seq: None, + ttl, + }); + } + if token_changed { + self.sync_agent_metadata_deadline(); + self.emit_pane_updated(ws_idx, pane_id); + } + encode_success(id, ResponseResult::Ok {}) } @@ -1551,41 +1615,6 @@ impl App { } } -fn normalize_metadata_source(value: String) -> Result { - let value = value.trim(); - if value.is_empty() { - return Err("metadata source must not be empty"); - } - if value.chars().count() > METADATA_SOURCE_MAX_CHARS { - return Err("metadata source must be 80 characters or fewer"); - } - if !value.chars().all(metadata_source_char_is_valid) { - return Err( - "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", - ); - } - Ok(value.to_string()) -} - -fn metadata_source_char_is_valid(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-') -} - -fn normalize_metadata_ttl( - ttl_ms: Option, -) -> Result, &'static str> { - let Some(ttl_ms) = ttl_ms else { - return Ok(None); - }; - if ttl_ms < METADATA_TTL_MIN_MS { - return Err("metadata ttl_ms must be at least 1"); - } - if ttl_ms > METADATA_TTL_MAX_MS { - return Err("metadata ttl_ms must be 86400000 or less"); - } - Ok(Some(std::time::Duration::from_millis(ttl_ms))) -} - fn normalize_presentation_text(value: Option) -> Option { let trimmed = value?.trim().to_string(); let normalized: String = trimmed @@ -1850,6 +1879,7 @@ mod tests { use crate::{ api::schema::{ErrorResponse, SplitDirection, SuccessResponse}, config::Config, + detect::{Agent, AgentState}, workspace::Workspace, }; @@ -1902,13 +1932,12 @@ mod tests { source: "user:metadata.test-1".into(), agent: None, applies_to_source: None, - title: None, + title: Some("activity".into()), display_agent: None, - custom_status: Some("activity".into()), state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, seq: None, ttl_ms: None, @@ -3583,6 +3612,116 @@ mod tests { assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); } + #[test] + fn pane_metadata_tokens_patch_and_clear_through_dispatcher() { + let (mut app, pane_id) = app_with_test_workspace(); + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("model".into(), Some("opus".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("model".into(), "opus".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("model".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let mut params = metadata_params(pane_id.clone()); + params.title = None; + params.tokens = tokens; + let response = app.handle_api_request(crate::api::schema::Request { + id: "set".into(), + method: crate::api::schema::Method::PaneReportMetadata(params), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "get".into(), + method: crate::api::schema::Method::PaneGet(PaneTarget { + pane_id: pane_id.clone(), + }), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info"); + }; + assert_eq!(pane.tokens, expected); + } + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id].agent_metadata.is_empty()); + } + + #[test] + fn pane_tokens_are_independent_from_presentation_guards() { + let (mut app, pane_id) = app_with_test_workspace(); + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Claude), AgentState::Working); + let mut params = metadata_params(pane_id); + params.title = None; + params.agent = Some("codex".into()); + params.tokens = + std::collections::HashMap::from([("summary".into(), Some("global".into()))]); + + let response = app.handle_pane_report_metadata("guarded".into(), params); + + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + app.state.terminals[&terminal_id].metadata_tokens.values(), + std::collections::HashMap::from([("summary".into(), "global".into())]) + ); + } + + #[test] + fn pane_metadata_uses_one_sequence_for_presentation_and_tokens() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut presentation = metadata_params(pane_id.clone()); + presentation.seq = Some(10); + let response = app.handle_pane_report_metadata("presentation".into(), presentation); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut stale_token = metadata_params(pane_id.clone()); + stale_token.title = None; + stale_token.tokens = + std::collections::HashMap::from([("summary".into(), Some("stale".into()))]); + stale_token.seq = Some(9); + let response = app.handle_pane_report_metadata("stale".into(), stale_token); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id] + .metadata_tokens + .values() + .is_empty()); + } + #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index ead5c8e2..16d2fdb1 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -44,6 +44,7 @@ impl App { match &event.data { EventData::WorkspaceCreated { workspace } | EventData::WorkspaceUpdated { workspace } + | EventData::WorkspaceMetadataUpdated { workspace } | EventData::WorktreeCreated { workspace, .. } | EventData::WorktreeOpened { workspace, .. } => { self.plugin_context_for_workspace_info(workspace, correlation_id) @@ -132,7 +133,7 @@ impl App { context.tab_id = Some(layout.tab_id.clone()); context }), - EventData::PaneCreated { pane } => { + EventData::PaneCreated { pane } | EventData::PaneUpdated { pane } => { self.plugin_context_for_pane_info(pane, correlation_id) } EventData::PaneMoved { pane, .. } => { diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index b77b4715..1e0ec7d8 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -2369,7 +2369,6 @@ action = "missing" agent: "codex".into(), state: crate::api::schema::PaneAgentState::Working, message: None, - custom_status: None, seq: None, agent_session_id: None, agent_session_path: None, diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 8899ec44..b7260c17 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,10 +2,11 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget, }; use crate::app::App; +use super::super::api_helpers::{normalize_metadata_source, normalize_metadata_ttl}; use super::responses::{encode_error, encode_success}; impl App { @@ -153,6 +154,76 @@ impl App { encode_success(id, ResponseResult::WorkspaceList { workspaces }) } + pub(super) fn handle_workspace_report_metadata( + &mut self, + id: String, + params: WorkspaceReportMetadataParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + let source = match normalize_metadata_source(params.source) { + Ok(source) => source, + Err(message) => return encode_error(id, "invalid_metadata_source", message), + }; + let ttl = match normalize_metadata_ttl(params.ttl_ms) { + Ok(ttl) => ttl, + Err(message) => return encode_error(id, "invalid_metadata_ttl", message), + }; + let tokens = match super::super::api_helpers::normalize_metadata_tokens(params.tokens) { + Ok(tokens) => tokens, + Err(message) => return encode_error(id, "invalid_metadata_token", message), + }; + let Some(workspace) = self.state.workspaces.get_mut(index) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + if !crate::metadata_tokens::sequence_is_fresh( + &workspace.metadata_token_sequences, + &source, + params.seq, + ) { + return encode_success(id, ResponseResult::Ok {}); + } + if workspace.metadata_tokens.key_count_after_patch(&tokens) + > super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + { + return encode_error( + id, + "metadata_token_limit", + format!( + "workspace metadata may contain at most {} tokens", + super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + ), + ); + } + match crate::metadata_tokens::accept_sequence( + &mut workspace.metadata_token_sequences, + &source, + params.seq, + ) { + Ok(true) => {} + Ok(false) => return encode_success(id, ResponseResult::Ok {}), + Err(()) => { + return encode_error( + id, + "metadata_sequence_source_limit", + format!( + "workspace metadata may track at most {} sequenced sources", + crate::metadata_tokens::MAX_SEQUENCE_SOURCES + ), + ); + } + } + let changed = workspace + .metadata_tokens + .patch(tokens, ttl, std::time::Instant::now()); + if changed { + self.sync_agent_metadata_deadline(); + self.emit_workspace_token_updated(index); + } + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return workspace_not_found(id, &target.workspace_id); @@ -363,6 +434,90 @@ mod tests { })); } + #[test] + fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("jj_status".into(), Some("2 changes".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("jj_status".into(), "2 changes".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("jj_status".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::WorkspaceReportMetadata( + WorkspaceReportMetadataParams { + workspace_id: workspace_id.clone(), + source: "user:test".into(), + tokens, + seq: None, + ttl_ms: None, + }, + ), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(app.workspace_info(0).tokens, expected); + } + + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } + if workspace.tokens.get("summary").map(String::as_str) == Some("done") + && !workspace.tokens.contains_key("jj_status") + ))); + } + + #[test] + fn workspace_token_ttl_expires_through_runtime_and_emits_update() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + let response = app.handle_workspace_report_metadata( + "req".into(), + WorkspaceReportMetadataParams { + workspace_id, + source: "user:test".into(), + tokens: std::collections::HashMap::from([( + "summary".into(), + Some("temporary".into()), + )]), + seq: None, + ttl_ms: Some(1), + }, + ); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + let deadline = app.agent_metadata_deadline.expect("token deadline"); + + app.expire_metadata_at(deadline, deadline); + + assert!(app.workspace_info(0).tokens.is_empty()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } if workspace.tokens.is_empty() + ))); + } + #[test] fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c07a431d..c0a98357 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -83,11 +83,118 @@ pub(super) fn normalize_reported_agent_label(agent: &str) -> Option { Some(trimmed.to_string()) } -pub(super) fn normalize_custom_status(status: Option) -> Option { - let trimmed = status?.trim().to_string(); - let mut normalized = String::new(); - for ch in trimmed.chars().filter(|ch| !ch.is_control()).take(32) { - normalized.push(ch); +pub(super) const METADATA_TTL_MAX_MS: u64 = 86_400_000; +pub(super) const METADATA_SOURCE_MAX_CHARS: usize = 80; +const METADATA_TTL_MIN_MS: u64 = 1; +const MAX_METADATA_TOKEN_KEYS_PER_REQUEST: usize = 16; +pub(super) const MAX_METADATA_TOKEN_KEYS_PER_RESOURCE: usize = 32; +const MAX_METADATA_TOKEN_KEY_LEN: usize = 32; +const MAX_METADATA_TOKEN_VALUE_LEN: usize = 80; + +pub(super) fn normalize_metadata_source(value: String) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("metadata source must not be empty"); + } + if value.chars().count() > METADATA_SOURCE_MAX_CHARS { + return Err("metadata source must be 80 characters or fewer"); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')) + { + return Err( + "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", + ); + } + Ok(value.to_string()) +} + +pub(super) fn normalize_metadata_ttl( + ttl_ms: Option, +) -> Result, &'static str> { + let Some(ttl_ms) = ttl_ms else { + return Ok(None); + }; + if ttl_ms < METADATA_TTL_MIN_MS { + return Err("metadata ttl_ms must be at least 1"); + } + if ttl_ms > METADATA_TTL_MAX_MS { + return Err("metadata ttl_ms must be 86400000 or less"); + } + Ok(Some(std::time::Duration::from_millis(ttl_ms))) +} + +pub(super) fn normalize_metadata_tokens( + tokens: std::collections::HashMap>, +) -> Result>, String> { + if tokens.is_empty() { + return Err("missing token to set or clear".into()); + } + if tokens.len() > MAX_METADATA_TOKEN_KEYS_PER_REQUEST { + return Err(format!( + "a metadata report may update at most {MAX_METADATA_TOKEN_KEYS_PER_REQUEST} tokens" + )); + } + + tokens + .into_iter() + .map(|(key, value)| { + if key.is_empty() + || key.len() > MAX_METADATA_TOKEN_KEY_LEN + || !key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + return Err(format!("invalid metadata token key: {key}")); + } + let value = value.and_then(|value| { + let normalized = value + .trim() + .chars() + .filter(|ch| !ch.is_control()) + .take(MAX_METADATA_TOKEN_VALUE_LEN) + .collect::(); + (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) + }); + Ok((key, value)) + }) + .collect() +} + +#[cfg(test)] +mod metadata_token_tests { + use super::*; + + #[test] + fn token_normalization_sanitizes_values_and_turns_empty_into_clear() { + let tokens = normalize_metadata_tokens(std::collections::HashMap::from([ + ("summary".into(), Some(" review\nready ".into())), + ("empty".into(), Some(" \n ".into())), + ("clear".into(), None), + ])) + .unwrap(); + + assert_eq!(tokens["summary"].as_deref(), Some("reviewready")); + assert_eq!(tokens["empty"], None); + assert_eq!(tokens["clear"], None); + } + + #[test] + fn token_normalization_rejects_invalid_or_unbounded_keys() { + for key in [ + "bad.name".to_string(), + "x".repeat(MAX_METADATA_TOKEN_KEY_LEN + 1), + ] { + assert!(normalize_metadata_tokens(std::collections::HashMap::from([( + key, + Some("value".into()), + )])) + .is_err()); + } + let too_many = (0..=MAX_METADATA_TOKEN_KEYS_PER_REQUEST) + .map(|index| (format!("key{index}"), Some("value".into()))) + .collect(); + assert!(normalize_metadata_tokens(too_many).is_err()); } - (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) } diff --git a/src/app/creation.rs b/src/app/creation.rs index 734c57aa..0277ba10 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -408,10 +408,12 @@ impl App { label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), title: presentation.title, + terminal_title: terminal.terminal_title.clone(), + terminal_title_stripped: terminal.terminal_title_stripped(), display_agent: presentation.display_agent, agent_status: pane_agent_status(terminal.state, pane.seen), - custom_status: presentation.custom_status, state_labels: presentation.state_labels, + tokens: terminal.metadata_tokens.values(), agent_session: terminal_agent_session_info(terminal), scroll, revision: terminal.revision, @@ -452,6 +454,7 @@ impl App { crate::workspace::public_tab_id_for_number(&ws.id, ws.active_tab + 1) }), agent_status: pane_agent_status(agg_state, seen), + tokens: ws.metadata_tokens.values(), worktree: ws .worktree_space() .map(|space| crate::api::schema::WorkspaceWorktreeInfo { diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 32ad14b3..acbdfb9a 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -443,7 +443,7 @@ impl AppState { detail_area, crate::ui::should_show_scrollbar(metrics), ); - if body.height < 2 || row < body.y || row >= body.y + body.height { + if body.height == 0 || row < body.y || row >= body.y + body.height { return None; } @@ -452,13 +452,14 @@ impl AppState { .into_iter() .skip(self.agent_panel_scroll) { - if row_y.saturating_add(1) >= body.y + body.height { + let height = crate::ui::agent_entry_height_in_body(self, &detail, body.height); + if row_y.saturating_add(height) > body.y + body.height { break; } - if row == row_y || row == row_y + 1 { + if row >= row_y && row < row_y.saturating_add(height) { return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); } - row_y = row_y.saturating_add(2); + row_y = row_y.saturating_add(height); if row_y < body.y + body.height { row_y = row_y.saturating_add(1); } @@ -691,6 +692,53 @@ mod tests { ); } + #[test] + fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() { + let mut app = app_for_mouse_test(); + let first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + let second = Workspace::test_new("two"); + let second_pane = second.tabs[0].root_pane; + app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + for (ws_idx, pane_id, agent) in + [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)] + { + let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Workspace], + ], + ); + let detail_area = app.state.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); + let body = crate::ui::agent_panel_body_rect( + detail_area, + crate::ui::should_show_scrollbar(metrics), + ); + + assert_eq!( + app.state.agent_detail_target_at(body.y), + Some((0, 0, first_pane)) + ); + assert_eq!(app.state.agent_detail_target_at(body.y + 1), None); + assert_eq!( + app.state.agent_detail_target_at(body.y + 3), + Some((1, 0, second_pane)) + ); + } + #[test] fn clicking_agent_panel_toggle_switches_sort() { let mut app = app_for_mouse_test(); @@ -866,6 +914,14 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Workspace], + ], + ); app.state.agent_panel_scroll = 1; let detail_area = app.state.agent_panel_rect(); @@ -873,7 +929,7 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), body.x + 1, - body.y, + body.y + 1, )); assert_eq!(app.state.workspaces[0].active_tab, second_tab); diff --git a/src/app/mod.rs b/src/app/mod.rs index 99fc12a6..bff04c44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,7 @@ mod runtime_mutations; mod session; pub mod state; mod terminal_targets; +mod terminal_titles; mod theme_sync; mod worktrees; @@ -598,6 +599,8 @@ impl App { sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, sidebar_section_split, agent_panel_sort, + sidebar_agents: config.ui.sidebar.agents.clone(), + sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, mouse_capture: config.ui.mouse_capture, copy_on_select: config.ui.copy_on_select, @@ -879,12 +882,19 @@ impl App { if self.render_dirty.load(Ordering::Acquire) { needs_render = true; } + let terminal_title_changed = self.sync_terminal_titles(); + if terminal_title_changed && self.terminal_title_sidebar_configured() { + needs_render = true; + } // Drain a bounded internal-event batch for responsiveness. API handlers // perform an exhaustive drain before reading pane/runtime state. if self.drain_internal_events() { needs_render = true; } + if self.expire_due_metadata(Instant::now()) { + needs_render = true; + } if self.drain_api_requests() { needs_render = true; } @@ -1390,6 +1400,8 @@ impl App { self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); + self.state.sidebar_agents = config.ui.sidebar.agents.clone(); + self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); self.state.agent_panel_scroll = 0; self.state.accent = crate::config::parse_color(&config.ui.accent); if !self.state.local_sound_playback && self.state.sound != config.ui.sound { @@ -2685,6 +2697,59 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn reload_config_updates_sidebar_token_rows() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-sidebar-tokens"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut app = test_app(); + + std::fs::write( + &path, + "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\n", + ) + .unwrap(); + app.state.agent_panel_scroll = 5; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.agent_panel_scroll, 0); + assert_eq!( + app.state.sidebar_agents.rows, + vec![vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::Custom("summary".into()), + ]] + ); + assert_eq!( + app.state.sidebar_agents.rows_by_agent["claude"], + vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]] + ); + assert_eq!( + app.state.sidebar_spaces.rows, + vec![vec![ + crate::config::SpaceSidebarToken::Workspace, + crate::config::SpaceSidebarToken::Custom("jj_status".into()), + ]] + ); + + let previous_agents = app.state.sidebar_agents.clone(); + std::fs::write( + &path, + "[ui.sidebar.agents]\nrows = [[\"agent\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude-code = [[\"terminal_title\"]]\n", + ) + .unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert_eq!(app.state.sidebar_agents, previous_agents); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn reload_config_updates_sidebar_collapsed_mode() { let _guard = config_env_lock().lock().unwrap(); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 83ca2ea6..88081743 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -62,7 +62,8 @@ impl App { msg: crate::api::ApiRequestMessage, ) -> bool { let previous_mode = self.state.mode; - let mut changed = crate::api::request_changes_ui(&msg.request); + let mut changed = self.expire_due_metadata(Instant::now()); + changed |= crate::api::request_changes_ui(&msg.request); let skip_default_workspace = matches!( &msg.request.method, crate::api::schema::Method::ServerStop(_) @@ -288,18 +289,7 @@ impl App { self.start_background_session_save(); } - if let Some(deadline) = self - .agent_metadata_deadline - .filter(|deadline| now >= *deadline) - { - let previous_toast = self.state.toast.clone(); - for update in self.state.expire_agent_metadata_at(deadline, now) { - self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); - self.emit_pane_state_update(&update); - } - self.sync_agent_metadata_deadline(); - changed = true; - } + changed |= self.expire_due_metadata(now); if geometry_dirty || resized { self.pending_agent_resume_deadline = None; @@ -337,6 +327,33 @@ impl App { self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); } + pub(crate) fn expire_due_metadata(&mut self, now: Instant) -> bool { + let Some(deadline) = self + .agent_metadata_deadline + .filter(|deadline| now >= *deadline) + else { + return false; + }; + self.expire_metadata_at(deadline, now); + true + } + + pub(crate) fn expire_metadata_at(&mut self, deadline: Instant, now: Instant) { + let previous_toast = self.state.toast.clone(); + for update in self.state.expire_agent_metadata_at(deadline, now) { + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + } + let (panes, workspaces) = self.state.expire_metadata_tokens(now); + for (ws_idx, pane_id) in panes { + self.emit_pane_updated(ws_idx, pane_id); + } + for ws_idx in workspaces { + self.emit_workspace_token_updated(ws_idx); + } + self.sync_agent_metadata_deadline(); + } + pub(crate) fn sync_animation_timer(&mut self, now: Instant) { self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL); } diff --git a/src/app/state.rs b/src/app/state.rs index be858678..d2315422 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1403,6 +1403,8 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, + pub sidebar_agents: crate::config::AgentsSidebarConfig, + pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only /// captures mouse while the focused pane app requests mouse reporting. @@ -1764,6 +1766,8 @@ impl AppState { sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, + sidebar_agents: crate::config::AgentsSidebarConfig::default(), + sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, mouse_capture: true, copy_on_select: true, diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs new file mode 100644 index 00000000..3c7d9711 --- /dev/null +++ b/src/app/terminal_titles.rs @@ -0,0 +1,148 @@ +use super::App; + +impl App { + pub(crate) fn terminal_title_sidebar_configured(&self) -> bool { + let config = &self.state.sidebar_agents; + std::iter::once(&config.rows) + .chain(config.rows_by_agent.values()) + .flatten() + .flatten() + .any(|token| { + matches!( + token, + crate::config::AgentSidebarToken::TerminalTitle + | crate::config::AgentSidebarToken::TerminalTitleStripped + ) + }) + } + + pub(crate) fn sync_terminal_titles(&mut self) -> bool { + let mut observations = Vec::new(); + for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { + for tab in &workspace.tabs { + for (pane_id, pane) in &tab.panes { + let terminal_id = &pane.attached_terminal_id; + let Some(runtime) = self.terminal_runtimes.get(terminal_id) else { + continue; + }; + observations.push(( + ws_idx, + *pane_id, + terminal_id.clone(), + runtime.terminal_title(), + )); + } + } + } + + let mut raw_changed = false; + let mut publish = Vec::new(); + for (ws_idx, pane_id, terminal_id, title) in observations { + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + continue; + }; + let change = terminal.set_terminal_title(title); + raw_changed |= change.raw_changed; + if change.stripped_changed { + publish.push((ws_idx, pane_id)); + } + } + + for (ws_idx, pane_id) in publish { + self.emit_pane_updated(ws_idx, pane_id); + } + + raw_changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::detect::{Agent, AgentState}; + use crate::workspace::Workspace; + + #[tokio::test] + async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(Agent::Claude); + terminal.state = AgentState::Working; + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes()); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题")); + assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + assert_eq!(pane.title, None); + assert_eq!(pane.agent_status, crate::api::schema::AgentStatus::Working); + assert_eq!(pane.revision, 1); + let agent = app.collect_agent_infos().pop().unwrap(); + assert_eq!(agent.terminal_title.as_deref(), Some("⠋ 修复🙂标题")); + assert_eq!(agent.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes()); + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题")); + assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + assert_eq!(pane.revision, 1); + assert_eq!(pane_updated_events(&event_hub), 1); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]0;Done reviewing\x07"); + assert!(app.sync_terminal_titles()); + assert_eq!(pane_updated_events(&event_hub), 2); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]0;\x07"); + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title, None); + assert_eq!(pane.terminal_title_stripped, None); + assert_eq!(pane.revision, 3); + assert_eq!(pane_updated_events(&event_hub), 3); + } + + #[test] + fn override_only_terminal_title_token_requests_sidebar_redraws() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]], + ); + + assert!(app.terminal_title_sidebar_configured()); + } + + fn pane_updated_events(event_hub: &crate::api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == crate::api::schema::EventKind::PaneUpdated) + .count() + } +} diff --git a/src/cli.rs b/src/cli.rs index 494ed14e..1ce262b4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,6 +30,16 @@ const TERMINAL_SESSION_OBSERVE_USAGE: &str = const TERMINAL_SESSION_CONTROL_USAGE: &str = "usage: herdr terminal session control [--takeover] [--cols N] [--rows N]"; +pub(crate) fn parse_token_assignment(raw: &str) -> Result<(String, Option), String> { + let Some((key, value)) = raw.split_once('=') else { + return Err("token must use NAME=VALUE".into()); + }; + if key.is_empty() { + return Err("token name must not be empty".into()); + } + Ok((key.to_string(), Some(value.to_string()))) +} + pub(crate) fn parse_env_assignment(raw: &str) -> Result<(String, String), String> { let Some((key, value)) = raw.split_once('=') else { return Err("env must use KEY=VALUE".into()); @@ -881,7 +891,6 @@ fn wait_for_agent_status_change( agent, title, display_agent, - custom_status, state_labels, } = event.data else { @@ -895,7 +904,6 @@ fn wait_for_agent_status_change( workspace_id, agent_status, agent, - custom_status, title, display_agent, state_labels, diff --git a/src/cli/pane.rs b/src/cli/pane.rs index a178eb4b..80e10c69 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -943,7 +943,7 @@ fn pane_run(args: &[String]) -> std::io::Result { fn pane_report_agent(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!("usage: herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); return Ok(2); }; @@ -952,7 +952,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result { let mut agent = None; let mut state = None; let mut message = None; - let mut custom_status = None; let mut seq = None; let mut agent_session_id = None; let mut agent_session_path = None; @@ -992,14 +991,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result { message = Some(value.clone()); index += 2; } - "--custom-status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --custom-status"); - return Ok(2); - }; - custom_status = Some(value.clone()); - index += 2; - } "--seq" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --seq"); @@ -1053,7 +1044,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result { agent, state, message, - custom_status, seq, agent_session_id, agent_session_path, @@ -1224,7 +1214,7 @@ fn pane_release_agent(args: &[String]) -> std::io::Result { fn pane_report_metadata(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-metadata --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); + eprintln!("usage: herdr pane report-metadata --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); return Ok(2); }; @@ -1234,11 +1224,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result { let mut applies_to_source = None; let mut title = None; let mut display_agent = None; - let mut custom_status = None; let mut state_labels = std::collections::HashMap::new(); + let mut tokens = std::collections::HashMap::new(); let mut clear_title = false; let mut clear_display_agent = false; - let mut clear_custom_status = false; let mut clear_state_labels = false; let mut seq = None; let mut ttl_ms = None; @@ -1294,18 +1283,6 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result { clear_display_agent = true; index += 1; } - "--custom-status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --custom-status"); - return Ok(2); - }; - custom_status = Some(value.clone()); - index += 2; - } - "--clear-custom-status" => { - clear_custom_status = true; - index += 1; - } "--state-label" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --state-label"); @@ -1330,6 +1307,29 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result { clear_state_labels = true; index += 1; } + "--token" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --token"); + return Ok(2); + }; + let (key, value) = match super::parse_token_assignment(value) { + Ok(token) => token, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + tokens.insert(key, value); + index += 2; + } + "--clear-token" => { + let Some(key) = args.get(index + 1) else { + eprintln!("missing value for --clear-token"); + return Ok(2); + }; + tokens.insert(key.clone(), None); + index += 2; + } "--seq" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --seq"); @@ -1369,7 +1369,6 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result { } if title.is_some() && clear_title || display_agent.is_some() && clear_display_agent - || custom_status.is_some() && clear_custom_status || !state_labels.is_empty() && clear_state_labels { eprintln!("cannot set and clear the same metadata field"); @@ -1377,11 +1376,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result { } if title.is_none() && display_agent.is_none() - && custom_status.is_none() && state_labels.is_empty() + && tokens.is_empty() && !clear_title && !clear_display_agent - && !clear_custom_status && !clear_state_labels { eprintln!("missing metadata field to set or clear"); @@ -1395,11 +1393,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result { applies_to_source, title, display_agent, - custom_status, state_labels, + tokens, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, seq, ttl_ms, @@ -1433,10 +1430,10 @@ fn print_pane_help() { eprintln!(" herdr pane close "); eprintln!(" herdr pane send-text "); eprintln!(" herdr pane send-keys [key ...]"); - eprintln!(" herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!(" herdr pane report-agent --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane report-agent-session --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane release-agent --source ID --agent LABEL [--seq N]"); - eprintln!(" herdr pane report-metadata --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); + eprintln!(" herdr pane report-metadata --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); eprintln!(" herdr pane run "); } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 4c68e3a5..f27eed49 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -160,6 +160,16 @@ fn workspace_command() -> Command { .arg(required("workspace_id", "WORKSPACE_ID")) .arg(required("label", "LABEL").num_args(1..)), ) + .subcommand( + Command::new("report-metadata") + .about("Report display-only workspace metadata") + .arg(required("workspace_id", "WORKSPACE_ID")) + .arg(option("source", "ID")) + .arg(repeatable_option("token", "NAME=VALUE")) + .arg(repeatable_option("clear-token", "NAME")) + .arg(option("seq", "N")) + .arg(option("ttl-ms", "N")), + ) .subcommand(id_command("close", "workspace_id", "Close a workspace")) } @@ -467,7 +477,6 @@ fn report_agent_command() -> Command { .arg(option("agent", "LABEL")) .arg(pane_agent_state_option("state")) .arg(option("message", "TEXT")) - .arg(option("custom-status", "TEXT")) .arg(option("seq", "N")) .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) @@ -505,10 +514,10 @@ fn report_metadata_command() -> Command { .arg(flag("clear-title")) .arg(option("display-agent", "TEXT")) .arg(flag("clear-display-agent")) - .arg(option("custom-status", "TEXT")) - .arg(flag("clear-custom-status")) .arg(option("state-label", "STATUS=TEXT")) .arg(flag("clear-state-labels")) + .arg(repeatable_option("token", "NAME=VALUE")) + .arg(repeatable_option("clear-token", "NAME")) .arg(option("seq", "N")) .arg(option("ttl-ms", "N")) } @@ -817,6 +826,10 @@ fn option(name: &'static str, value_name: &'static str) -> Arg { .action(ArgAction::Set) } +fn repeatable_option(name: &'static str, value_name: &'static str) -> Arg { + option(name, value_name).action(ArgAction::Append) +} + fn path_option(name: &'static str, value_name: &'static str) -> Arg { option(name, value_name).value_hint(ValueHint::AnyPath) } diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs index e59a3663..525fc971 100644 --- a/src/cli/workspace.rs +++ b/src/cli/workspace.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; -use crate::api::schema::{WorkspaceCreateParams, WorkspaceRenameParams}; +use crate::api::schema::{ + Method, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, +}; pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { @@ -14,6 +16,7 @@ pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result { "get" => workspace_get(&args[1..]), "focus" => workspace_focus(&args[1..]), "rename" => workspace_rename(&args[1..]), + "report-metadata" => workspace_report_metadata(&args[1..]), "close" => workspace_close(&args[1..]), "help" | "--help" | "-h" => { print_workspace_help(); @@ -136,6 +139,91 @@ fn workspace_rename(args: &[String]) -> std::io::Result { }) } +fn workspace_report_metadata(args: &[String]) -> std::io::Result { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace report-metadata --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); + return Ok(2); + }; + let workspace_id = super::normalize_workspace_id(raw_workspace_id); + let mut source = None; + let mut tokens = HashMap::new(); + let mut seq = None; + let mut ttl_ms = None; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = Some(value.clone()); + index += 2; + } + "--token" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --token"); + return Ok(2); + }; + let (key, value) = match super::parse_token_assignment(value) { + Ok(token) => token, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + tokens.insert(key, value); + index += 2; + } + "--clear-token" => { + let Some(key) = args.get(index + 1) else { + eprintln!("missing value for --clear-token"); + return Ok(2); + }; + tokens.insert(key.clone(), None); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(super::parse_u64_flag("--seq", value)?); + index += 2; + } + "--ttl-ms" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --ttl-ms"); + return Ok(2); + }; + ttl_ms = Some(super::parse_u64_flag("--ttl-ms", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + let Some(source) = source.filter(|source| !source.trim().is_empty()) else { + eprintln!("missing required --source"); + return Ok(2); + }; + if tokens.is_empty() { + eprintln!("missing token to set or clear"); + return Ok(2); + } + super::send_ok_request(Method::WorkspaceReportMetadata( + WorkspaceReportMetadataParams { + workspace_id, + source, + tokens, + seq, + ttl_ms, + }, + )) +} + fn workspace_close(args: &[String]) -> std::io::Result { let Some(raw_workspace_id) = args.first() else { eprintln!("usage: herdr workspace close "); @@ -156,5 +244,6 @@ fn print_workspace_help() { eprintln!(" herdr workspace get "); eprintln!(" herdr workspace focus "); eprintln!(" herdr workspace rename