diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-13 03:26:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-13 03:30:13 +0300 |
| commit | 5cfe5e5e730246948b0c9428ad876b6938590aad (patch) | |
| tree | 53d4bbe1aff6887f63f279eed1b5e4e11e7331b7 /src | |
| parent | 7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff) | |
feat: add configurable sidebar metadata tokens
Diffstat (limited to 'src')
54 files changed, 3231 insertions, 998 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index b285eb3d..6f8fa9df 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,6 +28,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceMove(_) + | Method::WorkspaceReportMetadata(_) | Method::WorkspaceClose(_) | Method::WorktreeCreate(_) | Method::WorktreeOpen(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 1a51dfbc..4fb3d841 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -75,6 +75,8 @@ pub enum Method { WorkspaceRename(WorkspaceRenameParams), #[serde(rename = "workspace.move")] WorkspaceMove(WorkspaceMoveParams), + #[serde(rename = "workspace.report_metadata")] + WorkspaceReportMetadata(WorkspaceReportMetadataParams), #[serde(rename = "workspace.close")] WorkspaceClose(WorkspaceTarget), #[serde(rename = "worktree.list")] diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index e5f319e0..969383c4 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -57,14 +57,19 @@ pub struct AgentInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title_stripped: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option<String>, pub agent_status: AgentStatus, #[serde(default, skip_serializing_if = "super::is_false")] pub screen_detection_skipped: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub state_labels: HashMap<String, String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_values_schema")] + pub tokens: HashMap<String, String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option<AgentSessionInfo>, pub workspace_id: String, diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index 3d8376ed..e22fa1b3 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -1,5 +1,27 @@ use serde::{Deserialize, Serialize}; +pub(super) fn metadata_token_patch_schema( + _generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + schemars::json_schema!({ + "type": "object", + "maxProperties": 16, + "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,32}$" }, + "additionalProperties": { "type": ["string", "null"] } + }) +} + +pub(super) fn metadata_token_values_schema( + _generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + schemars::json_schema!({ + "type": "object", + "maxProperties": 32, + "propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,32}$" }, + "additionalProperties": { "type": "string" } + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] pub struct EmptyParams {} diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 41b9907d..25ccc8ec 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -20,6 +20,8 @@ pub enum Subscription { WorkspaceCreated {}, #[serde(rename = "workspace.updated")] WorkspaceUpdated {}, + #[serde(rename = "workspace.metadata_updated")] + WorkspaceMetadataUpdated {}, #[serde(rename = "workspace.renamed")] WorkspaceRenamed {}, #[serde(rename = "workspace.moved")] @@ -48,6 +50,8 @@ pub enum Subscription { PaneCreated {}, #[serde(rename = "pane.closed")] PaneClosed {}, + #[serde(rename = "pane.updated")] + PaneUpdated {}, #[serde(rename = "pane.focused")] PaneFocused {}, #[serde(rename = "pane.moved")] @@ -188,6 +192,7 @@ pub enum EventMatch { pub enum EventKind { WorkspaceCreated, WorkspaceUpdated, + WorkspaceMetadataUpdated, WorkspaceClosed, WorkspaceRenamed, WorkspaceMoved, @@ -202,6 +207,7 @@ pub enum EventKind { TabFocused, PaneCreated, PaneClosed, + PaneUpdated, PaneFocused, PaneMoved, PaneOutputChanged, @@ -216,6 +222,7 @@ impl EventKind { match self { EventKind::WorkspaceCreated => "workspace.created", EventKind::WorkspaceUpdated => "workspace.updated", + EventKind::WorkspaceMetadataUpdated => "workspace.metadata_updated", EventKind::WorkspaceClosed => "workspace.closed", EventKind::WorkspaceRenamed => "workspace.renamed", EventKind::WorkspaceMoved => "workspace.moved", @@ -230,6 +237,7 @@ impl EventKind { EventKind::TabFocused => "tab.focused", EventKind::PaneCreated => "pane.created", EventKind::PaneClosed => "pane.closed", + EventKind::PaneUpdated => "pane.updated", EventKind::PaneFocused => "pane.focused", EventKind::PaneMoved => "pane.moved", EventKind::PaneOutputChanged => "pane.output_changed", @@ -245,6 +253,7 @@ impl EventKind { pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ EventKind::WorkspaceCreated, EventKind::WorkspaceUpdated, + EventKind::WorkspaceMetadataUpdated, EventKind::WorkspaceClosed, EventKind::WorkspaceRenamed, EventKind::WorkspaceMoved, @@ -259,6 +268,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ EventKind::TabFocused, EventKind::PaneCreated, EventKind::PaneClosed, + EventKind::PaneUpdated, EventKind::PaneFocused, EventKind::PaneMoved, EventKind::PaneOutputChanged, @@ -336,6 +346,8 @@ mod known_event_name_tests { let names = plugin_hook_event_names(); assert!(!names.contains(&"pane.output_changed")); assert!(!names.contains(&"layout.updated")); + assert!(!names.contains(&"workspace.metadata_updated")); + assert!(!names.contains(&"pane.updated")); assert!(names.contains(&"pane.moved")); } } @@ -385,8 +397,6 @@ pub struct PaneAgentStatusChangedEvent { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option<String>, @@ -410,6 +420,9 @@ pub enum EventData { WorkspaceUpdated { workspace: WorkspaceInfo, }, + WorkspaceMetadataUpdated { + workspace: WorkspaceInfo, + }, WorkspaceClosed { workspace_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -472,6 +485,9 @@ pub enum EventData { pane_id: String, workspace_id: String, }, + PaneUpdated { + pane: PaneInfo, + }, PaneFocused { pane_id: String, workspace_id: String, @@ -515,8 +531,6 @@ pub enum EventData { title: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] display_agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - custom_status: Option<String>, #[serde(default, skip_serializing_if = "HashMap::is_empty")] state_labels: HashMap<String, String>, }, diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 42a640ef..2e37e734 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -266,8 +266,6 @@ pub struct PaneReportAgentParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option<u64>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_id: Option<String>, @@ -302,21 +300,21 @@ pub struct PaneReportMetadataParams { pub title: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub state_labels: HashMap<String, String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_patch_schema")] + pub tokens: HashMap<String, Option<String>>, #[serde(default)] pub clear_title: bool, #[serde(default)] pub clear_display_agent: bool, #[serde(default)] - pub clear_custom_status: bool, - #[serde(default)] pub clear_state_labels: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option<u64>, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 86_400_000))] pub ttl_ms: Option<u64>, } @@ -356,12 +354,17 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title_stripped: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_agent: Option<String>, pub agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub state_labels: HashMap<String, String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_values_schema")] + pub tokens: HashMap<String, String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option<AgentSessionInfo>, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 5f603a18..adc7e61c 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -596,6 +596,7 @@ fn worktree_request_and_response_round_trip() { tab_count: 1, active_tab_id: "w_1:1".into(), agent_status: AgentStatus::Unknown, + tokens: HashMap::new(), worktree: Some(WorkspaceWorktreeInfo { repo_key: "/repo/herdr/.git".into(), repo_name: "herdr".into(), @@ -624,10 +625,12 @@ fn worktree_request_and_response_round_trip() { label: None, agent: None, title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status: AgentStatus::Unknown, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, @@ -679,6 +682,7 @@ fn worktree_lifecycle_events_round_trip() { tab_count: 1, active_tab_id: "w_2:1".into(), agent_status: AgentStatus::Unknown, + tokens: HashMap::new(), worktree: Some(WorkspaceWorktreeInfo { repo_key: "/repo/herdr/.git".into(), repo_name: "herdr".into(), @@ -1032,10 +1036,12 @@ fn create_response_round_trips_with_root_pane() { label: None, agent: None, title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status: AgentStatus::Unknown, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index 6934539c..a6177982 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -29,6 +29,19 @@ pub struct WorkspaceMoveParams { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct WorkspaceReportMetadataParams { + pub workspace_id: String, + pub source: String, + #[schemars(schema_with = "super::common::metadata_token_patch_schema")] + pub tokens: HashMap<String, Option<String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 86_400_000))] + pub ttl_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct WorkspaceInfo { pub workspace_id: String, pub number: usize, @@ -38,6 +51,9 @@ pub struct WorkspaceInfo { pub tab_count: usize, pub active_tab_id: String, pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars(schema_with = "super::common::metadata_token_values_schema")] + pub tokens: HashMap<String, String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option<WorkspaceWorktreeInfo>, } diff --git a/src/api/server.rs b/src/api/server.rs index 692e9388..9cef5ec1 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -322,6 +322,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", Method::WorkspaceMove(_) => "workspace.move", + Method::WorkspaceReportMetadata(_) => "workspace.report_metadata", Method::WorkspaceClose(_) => "workspace.close", Method::WorktreeList(_) => "worktree.list", Method::WorktreeCreate(_) => "worktree.create", @@ -811,10 +812,12 @@ mod tests { label: None, agent: Some("pi".into()), title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index b3a369c2..531fb7bb 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -66,7 +66,6 @@ pub(super) struct ActiveScrollChangedSubscription { struct PanePresentationSnapshot { title: Option<String>, display_agent: Option<String>, - custom_status: Option<String>, state_labels: std::collections::HashMap<String, String>, } @@ -75,7 +74,6 @@ impl PanePresentationSnapshot { Self { title: pane.title.clone(), display_agent: pane.display_agent.clone(), - custom_status: pane.custom_status.clone(), state_labels: pane.state_labels.clone(), } } @@ -83,13 +81,11 @@ impl PanePresentationSnapshot { fn from_event( title: &Option<String>, display_agent: &Option<String>, - custom_status: &Option<String>, state_labels: &std::collections::HashMap<String, String>, ) -> Self { Self { title: title.clone(), display_agent: display_agent.clone(), - custom_status: custom_status.clone(), state_labels: state_labels.clone(), } } @@ -124,6 +120,10 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceUpdated, last_sequence: 0, })), + Subscription::WorkspaceMetadataUpdated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceMetadataUpdated, + last_sequence: 0, + })), Subscription::WorkspaceRenamed {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::WorkspaceRenamed, last_sequence: 0, @@ -180,6 +180,10 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::PaneClosed, last_sequence: 0, })), + Subscription::PaneUpdated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::PaneUpdated, + last_sequence: 0, + })), Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::PaneFocused, last_sequence: 0, @@ -261,7 +265,6 @@ impl ActiveSubscription { agent: probe.agent, title: probe.title, display_agent: probe.display_agent, - custom_status: probe.custom_status, state_labels: probe.state_labels, }); @@ -373,7 +376,6 @@ impl ActiveAgentStatusChangedSubscription { agent, title, display_agent, - custom_status, state_labels, } = event.data else { @@ -387,12 +389,8 @@ impl ActiveAgentStatusChangedSubscription { } saw_status_event = true; - let current_presentation = PanePresentationSnapshot::from_event( - &title, - &display_agent, - &custom_status, - &state_labels, - ); + let current_presentation = + PanePresentationSnapshot::from_event(&title, &display_agent, &state_labels); self.last_status = Some(agent_status); self.last_presentation = Some(current_presentation); if self @@ -412,7 +410,6 @@ impl ActiveAgentStatusChangedSubscription { agent, title, display_agent, - custom_status, state_labels, }), }); @@ -479,7 +476,6 @@ impl ActiveAgentStatusChangedSubscription { agent: pane.agent, title: pane.title, display_agent: pane.display_agent, - custom_status: pane.custom_status, state_labels: pane.state_labels, }), }) @@ -613,7 +609,7 @@ mod tests { use super::*; use crate::api::schema::{AgentStatus, EventData, EventEnvelope, EventKind, PaneInfo}; - fn status_event(custom_status: Option<&str>) -> EventEnvelope { + fn presentation_event(title: Option<&str>) -> EventEnvelope { EventEnvelope { event: EventKind::PaneAgentStatusChanged, data: EventData::PaneAgentStatusChanged { @@ -621,9 +617,8 @@ mod tests { workspace_id: "workspace_1".into(), agent_status: AgentStatus::Working, agent: Some("pi".into()), - title: None, + title: title.map(str::to_string), display_agent: None, - custom_status: custom_status.map(str::to_string), state_labels: HashMap::new(), }, } @@ -641,10 +636,12 @@ mod tests { label: None, agent: None, title: None, + terminal_title: None, + terminal_title_stripped: None, display_agent: None, agent_status: AgentStatus::Unknown, - custom_status: None, state_labels: HashMap::new(), + tokens: HashMap::new(), agent_session: None, scroll, revision: 0, @@ -652,6 +649,28 @@ mod tests { } #[test] + fn workspace_metadata_subscription_uses_dedicated_event_kind() { + let event_hub = EventHub::default(); + let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel(); + let subscription = ActiveSubscription::new( + Subscription::WorkspaceMetadataUpdated {}, + "test", + 0, + &api_tx, + &event_hub, + ) + .expect("workspace metadata subscription"); + + assert!(matches!( + subscription, + ActiveSubscription::Event(ActiveEventSubscription { + event_kind: EventKind::WorkspaceMetadataUpdated, + .. + }) + )); + } + + #[test] fn scroll_subscription_emits_when_scroll_snapshot_changes() { let at_bottom = PaneScrollInfo { offset_from_bottom: 0, @@ -695,7 +714,6 @@ mod tests { last_presentation: Some(PanePresentationSnapshot { title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), }), last_sequence: event_hub.current_sequence(), @@ -703,8 +721,8 @@ mod tests { request_prefix: "test".into(), }; - event_hub.push(status_event(Some("short lived"))); - event_hub.push(status_event(None)); + event_hub.push(presentation_event(Some("short lived"))); + event_hub.push(presentation_event(None)); let set_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -712,7 +730,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else { panic!("wrong event data"); }; - assert_eq!(set_data.custom_status.as_deref(), Some("short lived")); + assert_eq!(set_data.title.as_deref(), Some("short lived")); let expiry_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -720,7 +738,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else { panic!("wrong event data"); }; - assert_eq!(expiry_data.custom_status, None); + assert_eq!(expiry_data.title, None); } #[test] @@ -733,7 +751,6 @@ mod tests { last_presentation: Some(PanePresentationSnapshot { title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), }), last_sequence: event_hub.current_sequence(), @@ -744,14 +761,13 @@ mod tests { agent: Some("pi".into()), title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), }), request_prefix: "test".into(), }; - event_hub.push(status_event(Some("short lived"))); - event_hub.push(status_event(None)); + event_hub.push(presentation_event(Some("short lived"))); + event_hub.push(presentation_event(None)); let set_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -759,7 +775,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else { panic!("wrong event data"); }; - assert_eq!(set_data.custom_status.as_deref(), Some("short lived")); + assert_eq!(set_data.title.as_deref(), Some("short lived")); let expiry_event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -767,7 +783,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else { panic!("wrong event data"); }; - assert_eq!(expiry_data.custom_status, None); + assert_eq!(expiry_data.title, None); } #[test] @@ -778,9 +794,8 @@ mod tests { status_filter: Some(AgentStatus::Working), last_status: Some(AgentStatus::Working), last_presentation: Some(PanePresentationSnapshot { - title: None, + title: Some("short lived".into()), display_agent: None, - custom_status: Some("short lived".into()), state_labels: HashMap::new(), }), last_sequence: event_hub.current_sequence(), @@ -789,15 +804,14 @@ mod tests { workspace_id: "workspace_1".into(), agent_status: AgentStatus::Working, agent: Some("pi".into()), - title: None, + title: Some("short lived".into()), display_agent: None, - custom_status: Some("short lived".into()), state_labels: HashMap::new(), }), request_prefix: "test".into(), }; - event_hub.push(status_event(Some("short lived"))); + event_hub.push(presentation_event(Some("short lived"))); let event = subscription .poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub) @@ -805,7 +819,7 @@ mod tests { let SubscriptionEventData::PaneAgentStatusChanged(data) = event.data else { panic!("wrong event data"); }; - assert_eq!(data.custom_status.as_deref(), Some("short lived")); + assert_eq!(data.title.as_deref(), Some("short lived")); assert!(subscription.initial_event.is_none()); } } diff --git a/src/api/wait.rs b/src/api/wait.rs index e70472c3..17ffb992 100644 --- a/src/api/wait.rs +++ b/src/api/wait.rs @@ -231,7 +231,6 @@ fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String { agent: data.agent, title: data.title, display_agent: data.display_agent, - custom_status: data.custom_status, state_labels: data.state_labels, }, }, diff --git a/src/app/actions.rs b/src/app/actions.rs index 5c263e7e..7cf43e79 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -537,15 +537,13 @@ impl AppState { .and_then(|terminal| terminal.agent_name.as_deref()) .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) }); - let custom_status = terminal.and_then(|terminal| terminal.effective_custom_status()); let state = terminal .map(|terminal| terminal.state) .unwrap_or(AgentState::Unknown); let status_label = terminal .map(|terminal| terminal.effective_presentation().state_labels) .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); - let status = custom_status - .or(status_label) + let status = status_label .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); let meta = match (agent_label, status.as_deref()) { (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), @@ -893,6 +891,16 @@ impl AppState { self.terminals .values() .filter_map(|terminal| terminal.next_agent_metadata_expiry()) + .chain( + self.terminals + .values() + .filter_map(|terminal| terminal.metadata_tokens.next_expiry()), + ) + .chain( + self.workspaces + .iter() + .filter_map(|workspace| workspace.metadata_tokens.next_expiry()), + ) .min() } @@ -946,6 +954,48 @@ impl AppState { .collect() } + pub(crate) fn expire_metadata_tokens( + &mut self, + now: std::time::Instant, + ) -> (Vec<(usize, PaneId)>, Vec<usize>) { + let pane_terminals = self + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + workspace.tabs.iter().flat_map(move |tab| { + tab.layout + .pane_ids() + .into_iter() + .filter_map(move |pane_id| { + workspace + .pane_state(pane_id) + .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone())) + }) + }) + }) + .collect::<Vec<_>>(); + let changed_panes = pane_terminals + .into_iter() + .filter_map(|(ws_idx, pane_id, terminal_id)| { + let terminal = self.terminals.get_mut(&terminal_id)?; + terminal.metadata_tokens.expire_at(now).then(|| { + terminal.revision = terminal.revision.saturating_add(1); + (ws_idx, pane_id) + }) + }) + .collect(); + let changed_workspaces = self + .workspaces + .iter_mut() + .enumerate() + .filter_map(|(ws_idx, workspace)| { + workspace.metadata_tokens.expire_at(now).then_some(ws_idx) + }) + .collect(); + (changed_panes, changed_workspaces) + } + pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(active_ws_idx) = self.active else { return false; @@ -1341,21 +1391,12 @@ impl AppState { self.view.sidebar_rect, self.sidebar_section_split, ); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let visible = metrics.viewport_rows; - if visible == 0 { - return; - } - - if idx < self.agent_panel_scroll { - self.agent_panel_scroll = idx; - } else if idx >= self.agent_panel_scroll.saturating_add(visible) { - self.agent_panel_scroll = idx.saturating_add(1).saturating_sub(visible); - } - - let max_scroll = - crate::ui::agent_panel_scroll_metrics(self, detail_area).max_offset_from_bottom; - self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll); + self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( + self, + detail_area, + self.agent_panel_scroll, + idx, + ); } pub(crate) fn terminal_ids_for_workspace( @@ -2559,7 +2600,6 @@ impl AppState { agent_label, state, message, - custom_status, seq, session_ref, } => { @@ -2576,7 +2616,6 @@ impl AppState { agent_label, state, message, - custom_status, session_ref, seq, ) @@ -2611,11 +2650,9 @@ impl AppState { applies_to_source, title, display_agent, - custom_status, state_labels, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, seq, ttl, @@ -2627,11 +2664,9 @@ impl AppState { applies_to_source, title, display_agent, - custom_status, state_labels, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, ttl, seq, @@ -4578,7 +4613,6 @@ mod tests { agent_label: "hermes".into(), state: AgentState::Blocked, message: None, - custom_status: None, seq: None, session_ref: None, }); @@ -4617,7 +4651,6 @@ mod tests { agent_label: "codex".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(1), session_ref: None, }); @@ -4666,7 +4699,6 @@ mod tests { agent_label: "claude".into(), state: AgentState::Blocked, message: None, - custom_status: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"), }); @@ -4749,7 +4781,6 @@ mod tests { agent_label: "devin".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"), }); @@ -4774,7 +4805,6 @@ mod tests { agent_label: "pi".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(20), session_ref: crate::agent_resume::AgentSessionRef::path(first_session), }); @@ -4787,7 +4817,6 @@ mod tests { agent_label: "pi".into(), state: AgentState::Working, message: None, - custom_status: None, seq: Some(21), session_ref: crate::agent_resume::AgentSessionRef::path(second_session), }); diff --git a/src/app/agents.rs b/src/app/agents.rs index bec375b9..83f97bd4 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -422,11 +422,13 @@ impl App { name: terminal.agent_name.clone(), agent: pane.agent, title: pane.title, + terminal_title: pane.terminal_title, + terminal_title_stripped: pane.terminal_title_stripped, display_agent: pane.display_agent, agent_status: pane.agent_status, screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(), - custom_status: pane.custom_status, state_labels: pane.state_labels, + tokens: pane.tokens, agent_session: pane.agent_session, workspace_id: pane.workspace_id, tab_id: pane.tab_id, diff --git a/src/app/api.rs b/src/app/api.rs index 9ebbbe56..c013d271 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -579,7 +579,6 @@ impl App { agent: update.agent_label.clone(), title: presentation.title, display_agent: presentation.display_agent, - custom_status: presentation.custom_status, state_labels: presentation.state_labels, }, }); @@ -745,6 +744,26 @@ impl App { self.event_hub.push(event); } + pub(crate) fn emit_pane_updated(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { + if let Some(pane) = self.pane_info(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneUpdated, + data: crate::api::schema::EventData::PaneUpdated { pane }, + }); + } + } + + pub(crate) fn emit_workspace_token_updated(&mut self, ws_idx: usize) { + // Token updates bypass plugin hooks so a hook cannot refresh its own + // token and recursively trigger workspace.updated. + self.event_hub.push(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceMetadataUpdated, + data: crate::api::schema::EventData::WorkspaceMetadataUpdated { + workspace: self.workspace_info(ws_idx), + }, + }); + } + pub(crate) fn sync_focus_events(&mut self) { let current_focus = self.state.active.and_then(|idx| { self.state @@ -816,6 +835,7 @@ impl App { &mut self, request: crate::api::schema::Request, ) -> String { + self.sync_terminal_titles(); use crate::api::schema::{ ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse, }; @@ -908,6 +928,9 @@ impl App { Method::WorkspaceMove(params) => { return self.handle_workspace_move(request.id, params); } + Method::WorkspaceReportMetadata(params) => { + return self.handle_workspace_report_metadata(request.id, params); + } Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target) } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 58dbeb19..17569682 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,15 +20,14 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, - normalize_reported_agent_label, + detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, + normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, + MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; +#[cfg(test)] +use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; use super::responses::{encode_error, encode_success}; -const METADATA_SOURCE_MAX_CHARS: usize = 80; -const METADATA_TTL_MIN_MS: u64 = 1; -const METADATA_TTL_MAX_MS: u64 = 86_400_000; - impl App { pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() { @@ -1237,7 +1236,6 @@ impl App { agent_label, state: detect_state_from_api(params.state), message: params.message, - custom_status: normalize_custom_status(params.custom_status), seq: params.seq, }); @@ -1279,7 +1277,7 @@ impl App { id: String, params: PaneReportMetadataParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶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<String, &'static str> { - let value = value.trim(); - if value.is_empty() { - return Err("metadata source must not be empty"); - } - if value.chars().count() > METADATA_SOURCE_MAX_CHARS { - return Err("metadata source must be 80 characters or fewer"); - } - if !value.chars().all(metadata_source_char_is_valid) { - return Err( - "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", - ); - } - Ok(value.to_string()) -} - -fn metadata_source_char_is_valid(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-') -} - -fn normalize_metadata_ttl( - ttl_ms: Option<u64>, -) -> Result<Option<std::time::Duration>, &'static str> { - let Some(ttl_ms) = ttl_ms else { - return Ok(None); - }; - if ttl_ms < METADATA_TTL_MIN_MS { - return Err("metadata ttl_ms must be at least 1"); - } - if ttl_ms > METADATA_TTL_MAX_MS { - return Err("metadata ttl_ms must be 86400000 or less"); - } - Ok(Some(std::time::Duration::from_millis(ttl_ms))) -} - fn normalize_presentation_text(value: Option<String>) -> Option<String> { let trimmed = value?.trim().to_string(); let normalized: String = trimmed @@ -1850,6 +1879,7 @@ mod tests { use crate::{ api::schema::{ErrorResponse, SplitDirection, SuccessResponse}, config::Config, + detect::{Agent, AgentState}, workspace::Workspace, }; @@ -1902,13 +1932,12 @@ mod tests { source: "user:metadata.test-1".into(), agent: None, applies_to_source: None, - title: None, + title: Some("activity".into()), display_agent: None, - custom_status: Some("activity".into()), state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, seq: None, ttl_ms: None, @@ -3584,6 +3613,116 @@ mod tests { } #[test] + fn pane_metadata_tokens_patch_and_clear_through_dispatcher() { + let (mut app, pane_id) = app_with_test_workspace(); + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("model".into(), Some("opus".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("model".into(), "opus".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("model".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let mut params = metadata_params(pane_id.clone()); + params.title = None; + params.tokens = tokens; + let response = app.handle_api_request(crate::api::schema::Request { + id: "set".into(), + method: crate::api::schema::Method::PaneReportMetadata(params), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "get".into(), + method: crate::api::schema::Method::PaneGet(PaneTarget { + pane_id: pane_id.clone(), + }), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info"); + }; + assert_eq!(pane.tokens, expected); + } + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id].agent_metadata.is_empty()); + } + + #[test] + fn pane_tokens_are_independent_from_presentation_guards() { + let (mut app, pane_id) = app_with_test_workspace(); + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Claude), AgentState::Working); + let mut params = metadata_params(pane_id); + params.title = None; + params.agent = Some("codex".into()); + params.tokens = + std::collections::HashMap::from([("summary".into(), Some("global".into()))]); + + let response = app.handle_pane_report_metadata("guarded".into(), params); + + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + app.state.terminals[&terminal_id].metadata_tokens.values(), + std::collections::HashMap::from([("summary".into(), "global".into())]) + ); + } + + #[test] + fn pane_metadata_uses_one_sequence_for_presentation_and_tokens() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut presentation = metadata_params(pane_id.clone()); + presentation.seq = Some(10); + let response = app.handle_pane_report_metadata("presentation".into(), presentation); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut stale_token = metadata_params(pane_id.clone()); + stale_token.title = None; + stale_token.tokens = + std::collections::HashMap::from([("summary".into(), Some("stale".into()))]); + stale_token.seq = Some(9); + let response = app.handle_pane_report_metadata("stale".into(), stale_token); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id] + .metadata_tokens + .values() + .is_empty()); + } + + #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index ead5c8e2..16d2fdb1 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -44,6 +44,7 @@ impl App { match &event.data { EventData::WorkspaceCreated { workspace } | EventData::WorkspaceUpdated { workspace } + | EventData::WorkspaceMetadataUpdated { workspace } | EventData::WorktreeCreated { workspace, .. } | EventData::WorktreeOpened { workspace, .. } => { self.plugin_context_for_workspace_info(workspace, correlation_id) @@ -132,7 +133,7 @@ impl App { context.tab_id = Some(layout.tab_id.clone()); context }), - EventData::PaneCreated { pane } => { + EventData::PaneCreated { pane } | EventData::PaneUpdated { pane } => { self.plugin_context_for_pane_info(pane, correlation_id) } EventData::PaneMoved { pane, .. } => { diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index b77b4715..1e0ec7d8 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -2369,7 +2369,6 @@ action = "missing" agent: "codex".into(), state: crate::api::schema::PaneAgentState::Working, message: None, - custom_status: None, seq: None, agent_session_id: None, agent_session_path: None, diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 8899ec44..b7260c17 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,10 +2,11 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget, }; use crate::app::App; +use super::super::api_helpers::{normalize_metadata_source, normalize_metadata_ttl}; use super::responses::{encode_error, encode_success}; impl App { @@ -153,6 +154,76 @@ impl App { encode_success(id, ResponseResult::WorkspaceList { workspaces }) } + pub(super) fn handle_workspace_report_metadata( + &mut self, + id: String, + params: WorkspaceReportMetadataParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶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); @@ -364,6 +435,90 @@ mod tests { } #[test] + fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("jj_status".into(), Some("2 changes".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("jj_status".into(), "2 changes".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("jj_status".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::WorkspaceReportMetadata( + WorkspaceReportMetadataParams { + workspace_id: workspace_id.clone(), + source: "user:test".into(), + tokens, + seq: None, + ttl_ms: None, + }, + ), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(app.workspace_info(0).tokens, expected); + } + + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } + if workspace.tokens.get("summary").map(String::as_str) == Some("done") + && !workspace.tokens.contains_key("jj_status") + ))); + } + + #[test] + fn workspace_token_ttl_expires_through_runtime_and_emits_update() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + let response = app.handle_workspace_report_metadata( + "req".into(), + WorkspaceReportMetadataParams { + workspace_id, + source: "user:test".into(), + tokens: std::collections::HashMap::from([( + "summary".into(), + Some("temporary".into()), + )]), + seq: None, + ttl_ms: Some(1), + }, + ); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + let deadline = app.agent_metadata_deadline.expect("token deadline"); + + app.expire_metadata_at(deadline, deadline); + + assert!(app.workspace_info(0).tokens.is_empty()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } if workspace.tokens.is_empty() + ))); + } + + #[test] fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c07a431d..c0a98357 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -83,11 +83,118 @@ pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { Some(trimmed.to_string()) } -pub(super) fn normalize_custom_status(status: Option<String>) -> Option<String> { - let trimmed = status?.trim().to_string(); - let mut normalized = String::new(); - for ch in trimmed.chars().filter(|ch| !ch.is_control()).take(32) { - normalized.push(ch); +pub(super) const METADATA_TTL_MAX_MS: u64 = 86_400_000; +pub(super) const METADATA_SOURCE_MAX_CHARS: usize = 80; +const METADATA_TTL_MIN_MS: u64 = 1; +const MAX_METADATA_TOKEN_KEYS_PER_REQUEST: usize = 16; +pub(super) const MAX_METADATA_TOKEN_KEYS_PER_RESOURCE: usize = 32; +const MAX_METADATA_TOKEN_KEY_LEN: usize = 32; +const MAX_METADATA_TOKEN_VALUE_LEN: usize = 80; + +pub(super) fn normalize_metadata_source(value: String) -> Result<String, &'static str> { + let value = value.trim(); + if value.is_empty() { + return Err("metadata source must not be empty"); + } + if value.chars().count() > METADATA_SOURCE_MAX_CHARS { + return Err("metadata source must be 80 characters or fewer"); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-')) + { + return Err( + "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", + ); + } + Ok(value.to_string()) +} + +pub(super) fn normalize_metadata_ttl( + ttl_ms: Option<u64>, +) -> Result<Option<std::time::Duration>, &'static str> { + let Some(ttl_ms) = ttl_ms else { + return Ok(None); + }; + if ttl_ms < METADATA_TTL_MIN_MS { + return Err("metadata ttl_ms must be at least 1"); + } + if ttl_ms > METADATA_TTL_MAX_MS { + return Err("metadata ttl_ms must be 86400000 or less"); + } + Ok(Some(std::time::Duration::from_millis(ttl_ms))) +} + +pub(super) fn normalize_metadata_tokens( + tokens: std::collections::HashMap<String, Option<String>>, +) -> Result<std::collections::HashMap<String, Option<String>>, String> { + if tokens.is_empty() { + return Err("missing token to set or clear".into()); + } + if tokens.len() > MAX_METADATA_TOKEN_KEYS_PER_REQUEST { + return Err(format!( + "a metadata report may update at most {MAX_METADATA_TOKEN_KEYS_PER_REQUEST} tokens" + )); + } + + tokens + .into_iter() + .map(|(key, value)| { + if key.is_empty() + || key.len() > MAX_METADATA_TOKEN_KEY_LEN + || !key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + return Err(format!("invalid metadata token key: {key}")); + } + let value = value.and_then(|value| { + let normalized = value + .trim() + .chars() + .filter(|ch| !ch.is_control()) + .take(MAX_METADATA_TOKEN_VALUE_LEN) + .collect::<String>(); + (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) + }); + Ok((key, value)) + }) + .collect() +} + +#[cfg(test)] +mod metadata_token_tests { + use super::*; + + #[test] + fn token_normalization_sanitizes_values_and_turns_empty_into_clear() { + let tokens = normalize_metadata_tokens(std::collections::HashMap::from([ + ("summary".into(), Some(" review\nready ".into())), + ("empty".into(), Some(" \n ".into())), + ("clear".into(), None), + ])) + .unwrap(); + + assert_eq!(tokens["summary"].as_deref(), Some("reviewready")); + assert_eq!(tokens["empty"], None); + assert_eq!(tokens["clear"], None); + } + + #[test] + fn token_normalization_rejects_invalid_or_unbounded_keys() { + for key in [ + "bad.name".to_string(), + "x".repeat(MAX_METADATA_TOKEN_KEY_LEN + 1), + ] { + assert!(normalize_metadata_tokens(std::collections::HashMap::from([( + key, + Some("value".into()), + )])) + .is_err()); + } + let too_many = (0..=MAX_METADATA_TOKEN_KEYS_PER_REQUEST) + .map(|index| (format!("key{index}"), Some("value".into()))) + .collect(); + assert!(normalize_metadata_tokens(too_many).is_err()); } - (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) } diff --git a/src/app/creation.rs b/src/app/creation.rs index 734c57aa..0277ba10 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -408,10 +408,12 @@ impl App { label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), title: presentation.title, + terminal_title: terminal.terminal_title.clone(), + terminal_title_stripped: terminal.terminal_title_stripped(), display_agent: presentation.display_agent, agent_status: pane_agent_status(terminal.state, pane.seen), - custom_status: presentation.custom_status, state_labels: presentation.state_labels, + tokens: terminal.metadata_tokens.values(), agent_session: terminal_agent_session_info(terminal), scroll, revision: terminal.revision, @@ -452,6 +454,7 @@ impl App { crate::workspace::public_tab_id_for_number(&ws.id, ws.active_tab + 1) }), agent_status: pane_agent_status(agg_state, seen), + tokens: ws.metadata_tokens.values(), worktree: ws .worktree_space() .map(|space| crate::api::schema::WorkspaceWorktreeInfo { diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 32ad14b3..acbdfb9a 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -443,7 +443,7 @@ impl AppState { detail_area, crate::ui::should_show_scrollbar(metrics), ); - if body.height < 2 || row < body.y || row >= body.y + body.height { + if body.height == 0 || row < body.y || row >= body.y + body.height { return None; } @@ -452,13 +452,14 @@ impl AppState { .into_iter() .skip(self.agent_panel_scroll) { - if row_y.saturating_add(1) >= body.y + body.height { + let height = crate::ui::agent_entry_height_in_body(self, &detail, body.height); + if row_y.saturating_add(height) > body.y + body.height { break; } - if row == row_y || row == row_y + 1 { + if row >= row_y && row < row_y.saturating_add(height) { return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); } - row_y = row_y.saturating_add(2); + row_y = row_y.saturating_add(height); if row_y < body.y + body.height { row_y = row_y.saturating_add(1); } @@ -692,6 +693,53 @@ mod tests { } #[test] + fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() { + let mut app = app_for_mouse_test(); + let first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + let second = Workspace::test_new("two"); + let second_pane = second.tabs[0].root_pane; + app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + for (ws_idx, pane_id, agent) in + [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)] + { + let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Workspace], + ], + ); + let detail_area = app.state.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); + let body = crate::ui::agent_panel_body_rect( + detail_area, + crate::ui::should_show_scrollbar(metrics), + ); + + assert_eq!( + app.state.agent_detail_target_at(body.y), + Some((0, 0, first_pane)) + ); + assert_eq!(app.state.agent_detail_target_at(body.y + 1), None); + assert_eq!( + app.state.agent_detail_target_at(body.y + 3), + Some((1, 0, second_pane)) + ); + } + + #[test] fn clicking_agent_panel_toggle_switches_sort() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; @@ -866,6 +914,14 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Workspace], + ], + ); app.state.agent_panel_scroll = 1; let detail_area = app.state.agent_panel_rect(); @@ -873,7 +929,7 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), body.x + 1, - body.y, + body.y + 1, )); assert_eq!(app.state.workspaces[0].active_tab, second_tab); diff --git a/src/app/mod.rs b/src/app/mod.rs index 99fc12a6..bff04c44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,7 @@ mod runtime_mutations; mod session; pub mod state; mod terminal_targets; +mod terminal_titles; mod theme_sync; mod worktrees; @@ -598,6 +599,8 @@ impl App { sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, sidebar_section_split, agent_panel_sort, + sidebar_agents: config.ui.sidebar.agents.clone(), + sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, mouse_capture: config.ui.mouse_capture, copy_on_select: config.ui.copy_on_select, @@ -879,12 +882,19 @@ impl App { if self.render_dirty.load(Ordering::Acquire) { needs_render = true; } + let terminal_title_changed = self.sync_terminal_titles(); + if terminal_title_changed && self.terminal_title_sidebar_configured() { + needs_render = true; + } // Drain a bounded internal-event batch for responsiveness. API handlers // perform an exhaustive drain before reading pane/runtime state. if self.drain_internal_events() { needs_render = true; } + if self.expire_due_metadata(Instant::now()) { + needs_render = true; + } if self.drain_api_requests() { needs_render = true; } @@ -1390,6 +1400,8 @@ impl App { self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); + self.state.sidebar_agents = config.ui.sidebar.agents.clone(); + self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); self.state.agent_panel_scroll = 0; self.state.accent = crate::config::parse_color(&config.ui.accent); if !self.state.local_sound_playback && self.state.sound != config.ui.sound { @@ -2686,6 +2698,59 @@ mod tests { } #[test] + fn reload_config_updates_sidebar_token_rows() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-sidebar-tokens"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut app = test_app(); + + std::fs::write( + &path, + "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\n", + ) + .unwrap(); + app.state.agent_panel_scroll = 5; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.agent_panel_scroll, 0); + assert_eq!( + app.state.sidebar_agents.rows, + vec![vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::Custom("summary".into()), + ]] + ); + assert_eq!( + app.state.sidebar_agents.rows_by_agent["claude"], + vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]] + ); + assert_eq!( + app.state.sidebar_spaces.rows, + vec![vec![ + crate::config::SpaceSidebarToken::Workspace, + crate::config::SpaceSidebarToken::Custom("jj_status".into()), + ]] + ); + + let previous_agents = app.state.sidebar_agents.clone(); + std::fs::write( + &path, + "[ui.sidebar.agents]\nrows = [[\"agent\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude-code = [[\"terminal_title\"]]\n", + ) + .unwrap(); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert_eq!(app.state.sidebar_agents, previous_agents); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn reload_config_updates_sidebar_collapsed_mode() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-sidebar-collapsed-mode"); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 83ca2ea6..88081743 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -62,7 +62,8 @@ impl App { msg: crate::api::ApiRequestMessage, ) -> bool { let previous_mode = self.state.mode; - let mut changed = crate::api::request_changes_ui(&msg.request); + let mut changed = self.expire_due_metadata(Instant::now()); + changed |= crate::api::request_changes_ui(&msg.request); let skip_default_workspace = matches!( &msg.request.method, crate::api::schema::Method::ServerStop(_) @@ -288,18 +289,7 @@ impl App { self.start_background_session_save(); } - if let Some(deadline) = self - .agent_metadata_deadline - .filter(|deadline| now >= *deadline) - { - let previous_toast = self.state.toast.clone(); - for update in self.state.expire_agent_metadata_at(deadline, now) { - self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); - self.emit_pane_state_update(&update); - } - self.sync_agent_metadata_deadline(); - changed = true; - } + changed |= self.expire_due_metadata(now); if geometry_dirty || resized { self.pending_agent_resume_deadline = None; @@ -337,6 +327,33 @@ impl App { self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); } + pub(crate) fn expire_due_metadata(&mut self, now: Instant) -> bool { + let Some(deadline) = self + .agent_metadata_deadline + .filter(|deadline| now >= *deadline) + else { + return false; + }; + self.expire_metadata_at(deadline, now); + true + } + + pub(crate) fn expire_metadata_at(&mut self, deadline: Instant, now: Instant) { + let previous_toast = self.state.toast.clone(); + for update in self.state.expire_agent_metadata_at(deadline, now) { + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + } + let (panes, workspaces) = self.state.expire_metadata_tokens(now); + for (ws_idx, pane_id) in panes { + self.emit_pane_updated(ws_idx, pane_id); + } + for ws_idx in workspaces { + self.emit_workspace_token_updated(ws_idx); + } + self.sync_agent_metadata_deadline(); + } + pub(crate) fn sync_animation_timer(&mut self, now: Instant) { self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL); } diff --git a/src/app/state.rs b/src/app/state.rs index be858678..d2315422 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1403,6 +1403,8 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, + pub sidebar_agents: crate::config::AgentsSidebarConfig, + pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only /// captures mouse while the focused pane app requests mouse reporting. @@ -1764,6 +1766,8 @@ impl AppState { sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, + sidebar_agents: crate::config::AgentsSidebarConfig::default(), + sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, mouse_capture: true, copy_on_select: true, diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs new file mode 100644 index 00000000..3c7d9711 --- /dev/null +++ b/src/app/terminal_titles.rs @@ -0,0 +1,148 @@ +use super::App; + +impl App { + pub(crate) fn terminal_title_sidebar_configured(&self) -> bool { + let config = &self.state.sidebar_agents; + std::iter::once(&config.rows) + .chain(config.rows_by_agent.values()) + .flatten() + .flatten() + .any(|token| { + matches!( + token, + crate::config::AgentSidebarToken::TerminalTitle + | crate::config::AgentSidebarToken::TerminalTitleStripped + ) + }) + } + + pub(crate) fn sync_terminal_titles(&mut self) -> bool { + let mut observations = Vec::new(); + for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { + for tab in &workspace.tabs { + for (pane_id, pane) in &tab.panes { + let terminal_id = &pane.attached_terminal_id; + let Some(runtime) = self.terminal_runtimes.get(terminal_id) else { + continue; + }; + observations.push(( + ws_idx, + *pane_id, + terminal_id.clone(), + runtime.terminal_title(), + )); + } + } + } + + let mut raw_changed = false; + let mut publish = Vec::new(); + for (ws_idx, pane_id, terminal_id, title) in observations { + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + continue; + }; + let change = terminal.set_terminal_title(title); + raw_changed |= change.raw_changed; + if change.stripped_changed { + publish.push((ws_idx, pane_id)); + } + } + + for (ws_idx, pane_id) in publish { + self.emit_pane_updated(ws_idx, pane_id); + } + + raw_changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::detect::{Agent, AgentState}; + use crate::workspace::Workspace; + + #[tokio::test] + async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(Agent::Claude); + terminal.state = AgentState::Working; + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes()); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题")); + assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + assert_eq!(pane.title, None); + assert_eq!(pane.agent_status, crate::api::schema::AgentStatus::Working); + assert_eq!(pane.revision, 1); + let agent = app.collect_agent_infos().pop().unwrap(); + assert_eq!(agent.terminal_title.as_deref(), Some("⠋ 修复🙂标题")); + assert_eq!(agent.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes()); + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题")); + assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题")); + assert_eq!(pane.revision, 1); + assert_eq!(pane_updated_events(&event_hub), 1); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]0;Done reviewing\x07"); + assert!(app.sync_terminal_titles()); + assert_eq!(pane_updated_events(&event_hub), 2); + + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]0;\x07"); + assert!(app.sync_terminal_titles()); + let pane = app.pane_info(0, pane_id).unwrap(); + assert_eq!(pane.terminal_title, None); + assert_eq!(pane.terminal_title_stripped, None); + assert_eq!(pane.revision, 3); + assert_eq!(pane_updated_events(&event_hub), 3); + } + + #[test] + fn override_only_terminal_title_token_requests_sidebar_redraws() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + app.state.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]], + ); + + assert!(app.terminal_title_sidebar_configured()); + } + + fn pane_updated_events(event_hub: &crate::api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == crate::api::schema::EventKind::PaneUpdated) + .count() + } +} @@ -30,6 +30,16 @@ const TERMINAL_SESSION_OBSERVE_USAGE: &str = const TERMINAL_SESSION_CONTROL_USAGE: &str = "usage: herdr terminal session control <target> [--takeover] [--cols N] [--rows N]"; +pub(crate) fn parse_token_assignment(raw: &str) -> Result<(String, Option<String>), String> { + let Some((key, value)) = raw.split_once('=') else { + return Err("token must use NAME=VALUE".into()); + }; + if key.is_empty() { + return Err("token name must not be empty".into()); + } + Ok((key.to_string(), Some(value.to_string()))) +} + pub(crate) fn parse_env_assignment(raw: &str) -> Result<(String, String), String> { let Some((key, value)) = raw.split_once('=') else { return Err("env must use KEY=VALUE".into()); @@ -881,7 +891,6 @@ fn wait_for_agent_status_change( agent, title, display_agent, - custom_status, state_labels, } = event.data else { @@ -895,7 +904,6 @@ fn wait_for_agent_status_change( workspace_id, agent_status, agent, - custom_status, title, display_agent, state_labels, diff --git a/src/cli/pane.rs b/src/cli/pane.rs index a178eb4b..80e10c69 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -943,7 +943,7 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> { fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); return Ok(2); }; @@ -952,7 +952,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { let mut agent = None; let mut state = None; let mut message = None; - let mut custom_status = None; let mut seq = None; let mut agent_session_id = None; let mut agent_session_path = None; @@ -992,14 +991,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { message = Some(value.clone()); index += 2; } - "--custom-status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --custom-status"); - return Ok(2); - }; - custom_status = Some(value.clone()); - index += 2; - } "--seq" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --seq"); @@ -1053,7 +1044,6 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { agent, state, message, - custom_status, seq, agent_session_id, agent_session_path, @@ -1224,7 +1214,7 @@ fn pane_release_agent(args: &[String]) -> std::io::Result<i32> { fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); + eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); return Ok(2); }; @@ -1234,11 +1224,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { let mut applies_to_source = None; let mut title = None; let mut display_agent = None; - let mut custom_status = None; let mut state_labels = std::collections::HashMap::new(); + let mut tokens = std::collections::HashMap::new(); let mut clear_title = false; let mut clear_display_agent = false; - let mut clear_custom_status = false; let mut clear_state_labels = false; let mut seq = None; let mut ttl_ms = None; @@ -1294,18 +1283,6 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { clear_display_agent = true; index += 1; } - "--custom-status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --custom-status"); - return Ok(2); - }; - custom_status = Some(value.clone()); - index += 2; - } - "--clear-custom-status" => { - clear_custom_status = true; - index += 1; - } "--state-label" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --state-label"); @@ -1330,6 +1307,29 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { clear_state_labels = true; index += 1; } + "--token" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --token"); + return Ok(2); + }; + let (key, value) = match super::parse_token_assignment(value) { + Ok(token) => token, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + tokens.insert(key, value); + index += 2; + } + "--clear-token" => { + let Some(key) = args.get(index + 1) else { + eprintln!("missing value for --clear-token"); + return Ok(2); + }; + tokens.insert(key.clone(), None); + index += 2; + } "--seq" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --seq"); @@ -1369,7 +1369,6 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { } if title.is_some() && clear_title || display_agent.is_some() && clear_display_agent - || custom_status.is_some() && clear_custom_status || !state_labels.is_empty() && clear_state_labels { eprintln!("cannot set and clear the same metadata field"); @@ -1377,11 +1376,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { } if title.is_none() && display_agent.is_none() - && custom_status.is_none() && state_labels.is_empty() + && tokens.is_empty() && !clear_title && !clear_display_agent - && !clear_custom_status && !clear_state_labels { eprintln!("missing metadata field to set or clear"); @@ -1395,11 +1393,10 @@ fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { applies_to_source, title, display_agent, - custom_status, state_labels, + tokens, clear_title, clear_display_agent, - clear_custom_status, clear_state_labels, seq, ttl_ms, @@ -1433,10 +1430,10 @@ fn print_pane_help() { eprintln!(" herdr pane close <pane_id>"); eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); - eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); - eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); + eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); eprintln!(" herdr pane run <pane_id> <command>"); } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 4c68e3a5..f27eed49 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -160,6 +160,16 @@ fn workspace_command() -> Command { .arg(required("workspace_id", "WORKSPACE_ID")) .arg(required("label", "LABEL").num_args(1..)), ) + .subcommand( + Command::new("report-metadata") + .about("Report display-only workspace metadata") + .arg(required("workspace_id", "WORKSPACE_ID")) + .arg(option("source", "ID")) + .arg(repeatable_option("token", "NAME=VALUE")) + .arg(repeatable_option("clear-token", "NAME")) + .arg(option("seq", "N")) + .arg(option("ttl-ms", "N")), + ) .subcommand(id_command("close", "workspace_id", "Close a workspace")) } @@ -467,7 +477,6 @@ fn report_agent_command() -> Command { .arg(option("agent", "LABEL")) .arg(pane_agent_state_option("state")) .arg(option("message", "TEXT")) - .arg(option("custom-status", "TEXT")) .arg(option("seq", "N")) .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) @@ -505,10 +514,10 @@ fn report_metadata_command() -> Command { .arg(flag("clear-title")) .arg(option("display-agent", "TEXT")) .arg(flag("clear-display-agent")) - .arg(option("custom-status", "TEXT")) - .arg(flag("clear-custom-status")) .arg(option("state-label", "STATUS=TEXT")) .arg(flag("clear-state-labels")) + .arg(repeatable_option("token", "NAME=VALUE")) + .arg(repeatable_option("clear-token", "NAME")) .arg(option("seq", "N")) .arg(option("ttl-ms", "N")) } @@ -817,6 +826,10 @@ fn option(name: &'static str, value_name: &'static str) -> Arg { .action(ArgAction::Set) } +fn repeatable_option(name: &'static str, value_name: &'static str) -> Arg { + option(name, value_name).action(ArgAction::Append) +} + fn path_option(name: &'static str, value_name: &'static str) -> Arg { option(name, value_name).value_hint(ValueHint::AnyPath) } diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs index e59a3663..525fc971 100644 --- a/src/cli/workspace.rs +++ b/src/cli/workspace.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; -use crate::api::schema::{WorkspaceCreateParams, WorkspaceRenameParams}; +use crate::api::schema::{ + Method, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, +}; pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { @@ -14,6 +16,7 @@ pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> { "get" => workspace_get(&args[1..]), "focus" => workspace_focus(&args[1..]), "rename" => workspace_rename(&args[1..]), + "report-metadata" => workspace_report_metadata(&args[1..]), "close" => workspace_close(&args[1..]), "help" | "--help" | "-h" => { print_workspace_help(); @@ -136,6 +139,91 @@ fn workspace_rename(args: &[String]) -> std::io::Result<i32> { }) } +fn workspace_report_metadata(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); + return Ok(2); + }; + let workspace_id = super::normalize_workspace_id(raw_workspace_id); + let mut source = None; + let mut tokens = HashMap::new(); + let mut seq = None; + let mut ttl_ms = None; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = Some(value.clone()); + index += 2; + } + "--token" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --token"); + return Ok(2); + }; + let (key, value) = match super::parse_token_assignment(value) { + Ok(token) => token, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + tokens.insert(key, value); + index += 2; + } + "--clear-token" => { + let Some(key) = args.get(index + 1) else { + eprintln!("missing value for --clear-token"); + return Ok(2); + }; + tokens.insert(key.clone(), None); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(super::parse_u64_flag("--seq", value)?); + index += 2; + } + "--ttl-ms" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --ttl-ms"); + return Ok(2); + }; + ttl_ms = Some(super::parse_u64_flag("--ttl-ms", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + let Some(source) = source.filter(|source| !source.trim().is_empty()) else { + eprintln!("missing required --source"); + return Ok(2); + }; + if tokens.is_empty() { + eprintln!("missing token to set or clear"); + return Ok(2); + } + super::send_ok_request(Method::WorkspaceReportMetadata( + WorkspaceReportMetadataParams { + workspace_id, + source, + tokens, + seq, + ttl_ms, + }, + )) +} + fn workspace_close(args: &[String]) -> std::io::Result<i32> { let Some(raw_workspace_id) = args.first() else { eprintln!("usage: herdr workspace close <workspace_id>"); @@ -156,5 +244,6 @@ fn print_workspace_help() { eprintln!(" herdr workspace get <workspace_id>"); eprintln!(" herdr workspace focus <workspace_id>"); eprintln!(" herdr workspace rename <workspace_id> <label>"); + eprintln!(" herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); eprintln!(" herdr workspace close <workspace_id>"); } diff --git a/src/config.rs b/src/config.rs index a2d3f89f..b9a476e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,7 @@ use crossterm::event::{KeyCode, KeyModifiers}; mod io; mod keybinds; mod model; +mod sidebar; mod sound; mod theme; @@ -23,6 +24,10 @@ pub use self::{ SidebarCollapsedModeConfig, ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, + sidebar::{ + AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SpaceSidebarToken, + SpacesSidebarConfig, + }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, }; diff --git a/src/config/model.rs b/src/config/model.rs index 16bc595d..60dd2471 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -4,8 +4,8 @@ use crossterm::event::KeyModifiers; use serde::{de, Deserialize, Deserializer, Serialize}; use super::{ - ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SoundConfig, - ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES, + ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SidebarConfig, + SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES, DEFAULT_SCROLLBACK_LIMIT_BYTES, }; @@ -810,6 +810,8 @@ pub struct UiConfig { pub hide_tab_bar_when_single_tab: bool, /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". pub agent_panel_sort: AgentPanelSortConfig, + /// Expanded sidebar row composition. + pub sidebar: SidebarConfig, /// Accent color for highlights, borders, and navigation UI. /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). pub accent: String, @@ -1003,6 +1005,7 @@ impl Default for UiConfig { show_agent_labels_on_pane_borders: false, hide_tab_bar_when_single_tab: false, agent_panel_sort: AgentPanelSortConfig::Spaces, + sidebar: SidebarConfig::default(), accent: "cyan".into(), toast: ToastConfig::default(), sound: SoundConfig::default(), diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs new file mode 100644 index 00000000..d0ae5240 --- /dev/null +++ b/src/config/sidebar.rs @@ -0,0 +1,400 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::detect::Agent; + +const MAX_SIDEBAR_ROWS: usize = 16; +const MAX_SIDEBAR_TOKENS_PER_ROW: usize = 16; + +fn deserialize_sidebar_rows<'de, D, T>(deserializer: D) -> Result<Vec<Vec<T>>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + let rows = Vec::<Vec<T>>::deserialize(deserializer)?; + validate_sidebar_rows(&rows).map_err(serde::de::Error::custom)?; + Ok(rows) +} + +fn validate_sidebar_rows<T>(rows: &[Vec<T>]) -> Result<(), String> { + if rows.len() > MAX_SIDEBAR_ROWS { + return Err(format!( + "sidebar layouts may contain at most {MAX_SIDEBAR_ROWS} rows" + )); + } + if rows + .iter() + .any(|row| row.len() > MAX_SIDEBAR_TOKENS_PER_ROW) + { + return Err(format!( + "sidebar rows may contain at most {MAX_SIDEBAR_TOKENS_PER_ROW} tokens" + )); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentSidebarToken { + StateIcon, + StateText, + Workspace, + Tab, + Pane, + Agent, + TerminalTitle, + TerminalTitleStripped, + Custom(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpaceSidebarToken { + StateIcon, + StateText, + Workspace, + Branch, + GitStatus, + Custom(String), +} + +fn parse_sidebar_token<'de, D, T>(deserializer: D, builtins: &[(&str, T)]) -> Result<T, D::Error> +where + D: serde::Deserializer<'de>, + T: Clone + From<String>, +{ + let value = String::deserialize(deserializer)?; + if let Some((_, token)) = builtins.iter().find(|(name, _)| *name == value) { + return Ok(token.clone()); + } + let Some(name) = value.strip_prefix('$') else { + return Err(serde::de::Error::custom(format!( + "unknown sidebar token `{value}`; custom tokens must start with `$`" + ))); + }; + if name.is_empty() + || name.len() > 32 + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + { + return Err(serde::de::Error::custom(format!( + "invalid custom sidebar token `{value}`" + ))); + } + Ok(T::from(name.to_string())) +} + +impl Serialize for AgentSidebarToken { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + match self { + Self::StateIcon => serializer.serialize_str("state_icon"), + Self::StateText => serializer.serialize_str("state_text"), + Self::Workspace => serializer.serialize_str("workspace"), + Self::Tab => serializer.serialize_str("tab"), + Self::Pane => serializer.serialize_str("pane"), + Self::Agent => serializer.serialize_str("agent"), + Self::TerminalTitle => serializer.serialize_str("terminal_title"), + Self::TerminalTitleStripped => serializer.serialize_str("terminal_title_stripped"), + Self::Custom(name) => serializer.serialize_str(&format!("${name}")), + } + } +} + +impl From<String> for AgentSidebarToken { + fn from(value: String) -> Self { + Self::Custom(value) + } +} + +impl<'de> Deserialize<'de> for AgentSidebarToken { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + parse_sidebar_token( + deserializer, + &[ + ("state_icon", Self::StateIcon), + ("state_text", Self::StateText), + ("workspace", Self::Workspace), + ("tab", Self::Tab), + ("pane", Self::Pane), + ("agent", Self::Agent), + ("terminal_title", Self::TerminalTitle), + ("terminal_title_stripped", Self::TerminalTitleStripped), + ], + ) + } +} + +impl Serialize for SpaceSidebarToken { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + match self { + Self::StateIcon => serializer.serialize_str("state_icon"), + Self::StateText => serializer.serialize_str("state_text"), + Self::Workspace => serializer.serialize_str("workspace"), + Self::Branch => serializer.serialize_str("branch"), + Self::GitStatus => serializer.serialize_str("git_status"), + Self::Custom(name) => serializer.serialize_str(&format!("${name}")), + } + } +} + +impl From<String> for SpaceSidebarToken { + fn from(value: String) -> Self { + Self::Custom(value) + } +} + +impl<'de> Deserialize<'de> for SpaceSidebarToken { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + parse_sidebar_token( + deserializer, + &[ + ("state_icon", Self::StateIcon), + ("state_text", Self::StateText), + ("workspace", Self::Workspace), + ("branch", Self::Branch), + ("git_status", Self::GitStatus), + ], + ) + } +} + +type AgentSidebarRows = Vec<Vec<AgentSidebarToken>>; +type SpaceSidebarRows = Vec<Vec<SpaceSidebarToken>>; + +fn deserialize_rows_by_agent<'de, D>( + deserializer: D, +) -> Result<BTreeMap<String, AgentSidebarRows>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let rows_by_agent = BTreeMap::<String, AgentSidebarRows>::deserialize(deserializer)?; + for (id, rows) in &rows_by_agent { + if crate::detect::parse_canonical_agent_label(id).is_none() { + return Err(serde::de::Error::custom(format!( + "unknown canonical agent id `{id}` in sidebar rows_by_agent" + ))); + } + validate_sidebar_rows(rows).map_err(serde::de::Error::custom)?; + } + Ok(rows_by_agent) +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct AgentsSidebarConfig { + #[serde(deserialize_with = "deserialize_sidebar_rows")] + pub rows: AgentSidebarRows, + #[serde(default, deserialize_with = "deserialize_rows_by_agent")] + pub rows_by_agent: BTreeMap<String, AgentSidebarRows>, +} + +impl AgentsSidebarConfig { + pub(crate) fn rows_for_agent(&self, agent: Option<Agent>) -> &AgentSidebarRows { + agent + .and_then(|agent| self.rows_by_agent.get(crate::detect::agent_label(agent))) + .unwrap_or(&self.rows) + } +} + +impl Default for AgentsSidebarConfig { + fn default() -> Self { + Self { + rows: vec![ + vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Workspace, + AgentSidebarToken::Tab, + ], + vec![AgentSidebarToken::Agent], + ], + rows_by_agent: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct SpacesSidebarConfig { + #[serde(deserialize_with = "deserialize_sidebar_rows")] + pub rows: SpaceSidebarRows, +} + +impl Default for SpacesSidebarConfig { + fn default() -> Self { + Self { + rows: vec![ + vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace], + vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus], + ], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct SidebarConfig { + pub agents: AgentsSidebarConfig, + pub spaces: SpacesSidebarConfig, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_compact_agent_and_existing_space_layouts() { + let config = SidebarConfig::default(); + assert_eq!( + config.agents.rows, + vec![ + vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Workspace, + AgentSidebarToken::Tab, + ], + vec![AgentSidebarToken::Agent], + ] + ); + assert!(config.agents.rows_by_agent.is_empty()); + assert_eq!( + config.spaces.rows, + vec![ + vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace], + vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus], + ] + ); + } + + #[test] + fn parses_builtin_and_arbitrary_custom_tokens() { + let config: crate::config::Config = toml::from_str( + r#" +[ui.sidebar.agents] +rows = [["state_icon", "workspace"], ["state_text", "agent", "$summary"], ["terminal_title", "terminal_title_stripped", "$terminal_title"]] + +[ui.sidebar.agents.rows_by_agent] +claude = [["terminal_title_stripped"], ["agent", "$model"]] + +[ui.sidebar.spaces] +rows = [["workspace"], ["$jj_status"]] +"#, + ) + .expect("sidebar token config"); + + assert_eq!( + config.ui.sidebar.agents.rows[1], + vec![ + AgentSidebarToken::StateText, + AgentSidebarToken::Agent, + AgentSidebarToken::Custom("summary".into()), + ] + ); + assert_eq!( + config.ui.sidebar.agents.rows[2], + vec![ + AgentSidebarToken::TerminalTitle, + AgentSidebarToken::TerminalTitleStripped, + AgentSidebarToken::Custom("terminal_title".into()), + ] + ); + assert_eq!( + config.ui.sidebar.agents.rows_by_agent["claude"], + vec![ + vec![AgentSidebarToken::TerminalTitleStripped], + vec![ + AgentSidebarToken::Agent, + AgentSidebarToken::Custom("model".into()), + ], + ] + ); + assert_eq!( + config.ui.sidebar.spaces.rows[1], + vec![SpaceSidebarToken::Custom("jj_status".into())] + ); + } + + #[test] + fn rejects_unknown_bare_and_malformed_custom_tokens() { + for token in ["summary", "$", "$bad.name"] { + let input = format!("[ui.sidebar.agents]\\nrows = [[\"{token}\"]]\\n"); + assert!(toml::from_str::<crate::config::Config>(&input).is_err()); + } + } + + #[test] + fn rejects_oversized_sidebar_layouts() { + let too_many_rows = std::iter::repeat_n("[\"agent\"]", MAX_SIDEBAR_ROWS + 1) + .collect::<Vec<_>>() + .join(","); + let input = format!("[ui.sidebar.agents]\nrows = [{too_many_rows}]\n"); + assert!(toml::from_str::<crate::config::Config>(&input).is_err()); + + let too_many_tokens = std::iter::repeat_n("\"workspace\"", MAX_SIDEBAR_TOKENS_PER_ROW + 1) + .collect::<Vec<_>>() + .join(","); + let input = format!("[ui.sidebar.spaces]\nrows = [[{too_many_tokens}]]\n"); + assert!(toml::from_str::<crate::config::Config>(&input).is_err()); + + let input = format!("[ui.sidebar.agents.rows_by_agent]\nclaude = [{too_many_rows}]\n"); + assert!(toml::from_str::<crate::config::Config>(&input).is_err()); + } + + #[test] + fn accepts_every_canonical_agent_override_key() { + let agents = [ + Agent::Pi, + Agent::Claude, + Agent::Codex, + Agent::Gemini, + Agent::Cursor, + Agent::Devin, + Agent::Antigravity, + Agent::Cline, + Agent::Omp, + Agent::Mastracode, + Agent::OpenCode, + Agent::GithubCopilot, + Agent::Kimi, + Agent::Kiro, + Agent::Droid, + Agent::Amp, + Agent::Grok, + Agent::Hermes, + Agent::Kilo, + Agent::Qodercli, + Agent::Maki, + ]; + let entries = agents + .iter() + .map(|agent| format!("{} = [[\"agent\"]]", crate::detect::agent_label(*agent))) + .collect::<Vec<_>>() + .join("\n"); + let input = format!("[ui.sidebar.agents.rows_by_agent]\n{entries}\n"); + let config: crate::config::Config = toml::from_str(&input).expect("canonical keys"); + + assert_eq!(config.ui.sidebar.agents.rows_by_agent.len(), agents.len()); + } + + #[test] + fn rejects_alias_case_whitespace_and_unknown_override_keys() { + for key in ["claude-code", "Claude", "' claude '", "unknown"] { + let input = format!("[ui.sidebar.agents.rows_by_agent]\n{key} = [[\"agent\"]]\n"); + assert!( + toml::from_str::<crate::config::Config>(&input).is_err(), + "accepted key {key:?}" + ); + } + } +} diff --git a/src/events.rs b/src/events.rs index 64aa86f0..a3869ef7 100644 --- a/src/events.rs +++ b/src/events.rs @@ -73,7 +73,6 @@ pub enum AppEvent { agent_label: String, state: AgentState, message: Option<String>, - custom_status: Option<String>, seq: Option<u64>, session_ref: Option<crate::agent_resume::AgentSessionRef>, }, @@ -94,11 +93,9 @@ pub enum AppEvent { applies_to_source: Option<String>, title: Option<String>, display_agent: Option<String>, - custom_status: Option<String>, state_labels: std::collections::HashMap<String, String>, clear_title: bool, clear_display_agent: bool, - clear_custom_status: bool, clear_state_labels: bool, seq: Option<u64>, ttl: Option<std::time::Duration>, diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs index 6d7b82fe..e5121a88 100644 --- a/src/handoff_runtime.rs +++ b/src/handoff_runtime.rs @@ -17,6 +17,8 @@ pub(crate) struct HandoffRuntimeState { #[serde(default, skip_serializing_if = "Option::is_none")] pub input_state: Option<crate::pane::InputState>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_history_ansi: Option<String>, } diff --git a/src/main.rs b/src/main.rs index 57ca4dc2..06424fea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,7 @@ mod ipc; mod kitty_graphics; mod layout; mod logging; +mod metadata_tokens; mod pane; mod persist; mod platform; @@ -304,6 +305,20 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # "workspaces" is accepted as an alias for "spaces". # agent_panel_sort = "spaces" +# Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent, +# terminal_title, and terminal_title_stripped. +# Custom values reported through pane metadata use a $name token. +# [ui.sidebar.agents] +# rows = [["state_icon", "workspace", "tab"], ["agent"]] +# Optional canonical agent IDs replace the default rows for matching agents. +# [ui.sidebar.agents.rows_by_agent] +# claude = [["state_icon", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]] + +# Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status. +# Custom values reported through workspace metadata use a $name token, for example $jj_status. +# [ui.sidebar.spaces] +# rows = [["state_icon", "workspace"], ["branch", "git_status"]] + # Accent color for highlights, borders, and navigation UI. # Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) # accent = "cyan" diff --git a/src/metadata_tokens.rs b/src/metadata_tokens.rs new file mode 100644 index 00000000..f79e8ae2 --- /dev/null +++ b/src/metadata_tokens.rs @@ -0,0 +1,259 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MetadataToken { + value: String, + expires_at: Option<Instant>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MetadataTokens { + entries: HashMap<String, MetadataToken>, +} + +pub(crate) const MAX_SEQUENCE_SOURCES: usize = 32; + +pub(crate) fn sequence_is_fresh( + sequences: &HashMap<String, u64>, + source: &str, + seq: Option<u64>, +) -> bool { + seq.is_none_or(|seq| sequences.get(source).is_none_or(|last| seq > *last)) +} + +pub(crate) fn accept_sequence( + sequences: &mut HashMap<String, u64>, + source: &str, + seq: Option<u64>, +) -> Result<bool, ()> { + let Some(seq) = seq else { + return Ok(true); + }; + if !sequence_is_fresh(sequences, source, Some(seq)) { + return Ok(false); + } + if !sequences.contains_key(source) && sequences.len() >= MAX_SEQUENCE_SOURCES { + return Err(()); + } + sequences.insert(source.to_string(), seq); + Ok(true) +} + +impl MetadataTokens { + pub(crate) fn patch( + &mut self, + patch: HashMap<String, Option<String>>, + ttl: Option<Duration>, + now: Instant, + ) -> bool { + let expires_at = ttl.and_then(|ttl| now.checked_add(ttl)); + let mut changed = false; + for (key, value) in patch { + match value { + Some(value) => { + let token = MetadataToken { value, expires_at }; + if self.entries.get(&key) != Some(&token) { + self.entries.insert(key, token); + changed = true; + } + } + None => { + changed |= self.entries.remove(&key).is_some(); + } + } + } + changed + } + + pub(crate) fn key_count_after_patch(&self, patch: &HashMap<String, Option<String>>) -> usize { + let mut keys = self + .values() + .into_keys() + .collect::<std::collections::HashSet<_>>(); + for (key, value) in patch { + if value.is_some() { + keys.insert(key.clone()); + } else { + keys.remove(key); + } + } + keys.len() + } + + pub(crate) fn values(&self) -> HashMap<String, String> { + self.entries + .iter() + .map(|(key, token)| (key.clone(), token.value.clone())) + .collect() + } + + pub(crate) fn next_expiry(&self) -> Option<Instant> { + self.entries + .values() + .filter_map(|token| token.expires_at) + .min() + } + + pub(crate) fn expire_at(&mut self, now: Instant) -> bool { + let before = self.entries.len(); + self.entries + .retain(|_, token| token.expires_at.is_none_or(|deadline| deadline > now)); + self.entries.len() != before + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn patch(items: &[(&str, Option<&str>)]) -> HashMap<String, Option<String>> { + items + .iter() + .map(|(key, value)| ((*key).into(), value.map(str::to_string))) + .collect() + } + + #[test] + fn sequence_sources_are_bounded() { + let mut sequences = HashMap::new(); + for index in 0..MAX_SEQUENCE_SOURCES { + assert_eq!( + accept_sequence(&mut sequences, &format!("source-{index}"), Some(1)), + Ok(true) + ); + } + assert_eq!( + accept_sequence(&mut sequences, "one-too-many", Some(1)), + Err(()) + ); + assert_eq!( + accept_sequence(&mut sequences, "source-0", Some(1)), + Ok(false) + ); + assert_eq!( + accept_sequence(&mut sequences, "source-0", Some(2)), + Ok(true) + ); + } + + #[test] + fn patches_and_clears_individual_keys() { + let now = Instant::now(); + let mut tokens = MetadataTokens::default(); + tokens.patch( + patch(&[("summary", Some("one")), ("model", Some("opus"))]), + None, + now, + ); + tokens.patch( + patch(&[("summary", Some("two")), ("model", None)]), + None, + now, + ); + + assert_eq!( + tokens.values(), + HashMap::from([("summary".into(), "two".into())]) + ); + } + + #[test] + fn ttl_only_changes_keys_in_the_patch() { + let now = Instant::now(); + let deadline = now + Duration::from_secs(1); + let mut tokens = MetadataTokens::default(); + tokens.patch( + patch(&[("short", Some("one"))]), + Some(Duration::from_secs(1)), + now, + ); + tokens.patch(patch(&[("persistent", Some("two"))]), None, now); + + assert!(tokens.expire_at(deadline)); + assert_eq!( + tokens.values(), + HashMap::from([("persistent".into(), "two".into())]) + ); + } + + #[test] + fn values_remain_stable_until_expiry_mutates_state() { + let now = Instant::now(); + let deadline = now + Duration::from_secs(1); + let mut tokens = MetadataTokens::default(); + tokens.patch( + patch(&[("summary", Some("temporary"))]), + Some(Duration::from_secs(1)), + now, + ); + + assert_eq!( + tokens.values(), + HashMap::from([("summary".into(), "temporary".into())]) + ); + assert!(tokens.expire_at(deadline)); + assert!(tokens.values().is_empty()); + } + + #[test] + fn delayed_expiry_sweep_removes_every_token_due_at_now() { + let now = Instant::now(); + let mut tokens = MetadataTokens::default(); + tokens.patch( + patch(&[("first", Some("one"))]), + Some(Duration::from_secs(1)), + now, + ); + tokens.patch( + patch(&[("second", Some("two"))]), + Some(Duration::from_secs(2)), + now, + ); + + assert!(tokens.expire_at(now + Duration::from_secs(10))); + assert!(tokens.values().is_empty()); + } + + #[test] + fn stale_expiry_does_not_clear_replacement() { + let now = Instant::now(); + let first_deadline = now + Duration::from_secs(1); + let mut tokens = MetadataTokens::default(); + tokens.patch( + patch(&[("summary", Some("old"))]), + Some(Duration::from_secs(1)), + now, + ); + tokens.patch( + patch(&[("summary", Some("new"))]), + Some(Duration::from_secs(5)), + now, + ); + + assert!(!tokens.expire_at(first_deadline)); + assert_eq!( + tokens.values(), + HashMap::from([("summary".into(), "new".into())]) + ); + } + + #[test] + fn update_without_ttl_cancels_previous_expiry() { + let now = Instant::now(); + let deadline = now + Duration::from_secs(1); + let mut tokens = MetadataTokens::default(); + tokens.patch( + patch(&[("summary", Some("temporary"))]), + Some(Duration::from_secs(1)), + now, + ); + tokens.patch(patch(&[("summary", Some("persistent"))]), None, now); + + assert!(!tokens.expire_at(deadline)); + assert_eq!( + tokens.values(), + HashMap::from([("summary".into(), "persistent".into())]) + ); + } +} diff --git a/src/pane.rs b/src/pane.rs index 19d356dc..8f71031f 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1481,6 +1481,7 @@ impl PaneRuntime { }, keyboard_protocol_ansi: self.terminal.kitty_keyboard_state_ansi(), input_state: self.input_state(), + terminal_title: self.terminal_title(), initial_history_ansi: None, } } @@ -1668,6 +1669,7 @@ impl PaneRuntime { keyboard_protocol_flags, keyboard_protocol_ansi, input_state, + terminal_title, initial_history_ansi, } = state; let pane_id = PaneId::from_raw(pane_id); @@ -1688,6 +1690,7 @@ impl PaneRuntime { } let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); + pane_terminal.seed_terminal_title(terminal_title); if let Some(input_state) = input_state { pane_terminal.seed_handoff_input_state(input_state); } @@ -2450,6 +2453,10 @@ impl PaneRuntime { self.terminal.detection_text() } + pub fn terminal_title(&self) -> Option<String> { + self.terminal.terminal_title() + } + pub fn agent_osc_title(&self) -> String { self.terminal.agent_osc_title() } @@ -3121,16 +3128,20 @@ mod tests { #[cfg(unix)] #[tokio::test] - async fn handoff_runtime_state_captures_terminal_input_state() { + async fn handoff_runtime_state_captures_terminal_input_and_title_state() { let runtime = PaneRuntime::test_with_screen_bytes( 80, 24, b"\x1b[>5u\x1b[>4;2m\x1b[?1h\x1b[?2004h\x1b[?1004h\x1b[?1002h\x1b[?1006h", ); + runtime.test_process_pty_bytes("\x1b]2;✳ 修复🙂标题\x1b\\".as_bytes()); + runtime.terminal.clear_agent_osc_state(); + assert_eq!(runtime.agent_osc_title(), ""); let pane = runtime.handoff_runtime_state(12); assert_eq!(pane.keyboard_protocol_flags, 5); + assert_eq!(pane.terminal_title.as_deref(), Some("✳ 修复🙂标题")); assert_eq!( pane.input_state, Some(InputState { diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 92a743c4..838515e0 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -460,6 +460,7 @@ pub(super) struct AgentOscStateTracker { state: Osc52ForwarderState, body: Vec<u8>, latest_title: Option<String>, + terminal_title: Option<String>, latest_progress: Option<String>, } @@ -513,12 +514,14 @@ impl AgentOscStateTracker { if let Some((command, payload)) = parse_agent_osc_body(&self.body) { match command { b"0" | b"2" => { - if payload.is_empty() { - self.latest_title = None; + let title = if payload.is_empty() { + None } else { - self.latest_title = - Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); - } + let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS); + (!title.is_empty()).then_some(title) + }; + self.latest_title.clone_from(&title); + self.terminal_title = title; } b"9" => { self.latest_progress = @@ -530,6 +533,15 @@ impl AgentOscStateTracker { self.body.clear(); } + pub(super) fn terminal_title(&self) -> Option<&str> { + self.terminal_title.as_deref() + } + + #[cfg(unix)] + pub(super) fn seed_terminal_title(&mut self, title: Option<String>) { + self.terminal_title = title; + } + /// Returns the latest retained OSC title, or `""` if none has been seen or /// the last title was an empty clear. #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C @@ -1081,6 +1093,7 @@ mod tests { let mut t = AgentOscStateTracker::default(); t.observe("hello\x1b]0;braille title\x07world".as_bytes()); assert_eq!(t.latest_title(), "braille title"); + assert_eq!(t.terminal_title(), Some("braille title")); assert_eq!(t.latest_progress(), ""); } @@ -1101,6 +1114,29 @@ mod tests { // Then clear it with an empty payload (Codex pattern). t.observe(b"\x1b]0;\x07"); assert_eq!(t.latest_title(), ""); + assert_eq!(t.terminal_title(), None); + } + + #[test] + fn clearing_agent_evidence_preserves_the_terminal_title() { + let mut tracker = AgentOscStateTracker::default(); + tracker.observe("\x1b]2;✳ 修复🙂标题\x1b\\".as_bytes()); + + tracker.clear_retained(); + + assert_eq!(tracker.latest_title(), ""); + assert_eq!(tracker.terminal_title(), Some("✳ 修复🙂标题")); + } + + #[cfg(unix)] + #[test] + fn handoff_seed_does_not_restore_agent_detection_evidence() { + let mut tracker = AgentOscStateTracker::default(); + + tracker.seed_terminal_title(Some("✳ restored title".into())); + + assert_eq!(tracker.terminal_title(), Some("✳ restored title")); + assert_eq!(tracker.latest_title(), ""); } #[test] diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 5c7dfcb9..06cdea35 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -445,6 +445,10 @@ impl PaneTerminal { .maybe_restore_host_terminal_theme(pane_id, shell_pid) } + pub fn terminal_title(&self) -> Option<String> { + self.ghostty.terminal_title() + } + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.ghostty.agent_osc_title() @@ -985,6 +989,20 @@ impl GhosttyPaneTerminal { ) } + pub fn terminal_title(&self) -> Option<String> { + self.core + .lock() + .ok() + .and_then(|core| core.agent_osc_state.terminal_title().map(str::to_string)) + } + + #[cfg(unix)] + pub fn seed_terminal_title(&self, title: Option<String>) { + if let Ok(mut core) = self.core.lock() { + core.agent_osc_state.seed_terminal_title(title); + } + } + /// Returns the latest OSC 0/2 title retained for agent detection, or `""` /// if no title has been seen or the last update was an empty clear. #[allow(dead_code)] // exposed for Stage C (detection loop wiring) diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 162c13ed..cd44ccf7 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -413,6 +413,8 @@ fn restore_workspace( cached_git_ahead_behind: None, cached_git_space: crate::workspace::git_space_metadata(&snap.identity_cwd), worktree_space, + metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), + metadata_token_sequences: HashMap::new(), public_pane_numbers, next_public_pane_number, next_public_tab_number, diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 506a4d45..f78fd836 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -1076,7 +1076,6 @@ mod tests { "pi".into(), crate::detect::AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); diff --git a/src/server/headless.rs b/src/server/headless.rs index e6d2b833..6cec130a 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -463,6 +463,12 @@ impl HeadlessServer { needs_render = true; crate::render_prof::event("render.request.pty_dirty"); } + let terminal_title_changed = self.app.sync_terminal_titles(); + if terminal_title_changed && self.app.terminal_title_sidebar_configured() { + needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.terminal_title"); + } // 2. Drain a bounded internal-event batch. API handlers perform an // exhaustive forwarding-aware drain before reading pane/runtime state. @@ -471,6 +477,11 @@ impl HeadlessServer { needs_full_render = true; crate::render_prof::event("full_render_cause.internal_events"); } + if self.app.expire_due_metadata(Instant::now()) { + needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.metadata_expiry"); + } // 3. Drain API requests. if self.drain_api_requests_with_shutdown_check() { @@ -2789,6 +2800,8 @@ impl HeadlessServer { return false; } + let metadata_expired = self.app.expire_due_metadata(Instant::now()); + if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method { let response = match self.perform_live_handoff(params.clone()) { Ok(()) => serde_json::to_string(&api::schema::SuccessResponse { @@ -2832,7 +2845,7 @@ impl HeadlessServer { _ => {} } - let mut changed = api::request_changes_ui(&msg.request); + let mut changed = metadata_expired | api::request_changes_ui(&msg.request); let skip_default_workspace = skip_default_workspace_for_request || matches!( &msg.request.method, @@ -3713,13 +3726,7 @@ impl HeadlessServer { .agent_metadata_deadline .filter(|deadline| now >= *deadline) { - let previous_toast = self.app.state.toast.clone(); - for update in self.app.state.expire_agent_metadata_at(deadline, now) { - self.app - .refresh_new_herdr_toast_context_for_update(&update, &previous_toast); - self.app.emit_pane_state_update(&update); - } - self.app.sync_agent_metadata_deadline(); + self.app.expire_metadata_at(deadline, now); changed = true; } @@ -4260,6 +4267,91 @@ mod tests { } } + #[tokio::test] + async fn headless_api_reads_latest_title_without_spinner_event_flooding() { + let event_hub = api::EventHub::default(); + let mut server = test_headless_server_with_event_hub(event_hub.clone()); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.sidebar_agents.rows = vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]]; + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(crate::detect::Agent::Claude); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + + let first = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); + assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); + let (buffer, _) = crate::server::render_stream::render_virtual_with_runtime_registry( + &mut server.app.state, + &server.app.terminal_runtimes, + Rect::new(0, 0, 100, 30), + true, + crate::kitty_graphics::HostCellSize::default(), + ); + let rendered = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::<String>(); + assert!(rendered.contains("task"), "rendered frame: {rendered:?}"); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); + let second = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); + assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); + } + + fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "list-titles".into(), + method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), + }, + respond_to, + }); + let response: api::schema::SuccessResponse = + serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); + let api::schema::ResponseResult::PaneList { panes } = response.result else { + panic!("expected pane list"); + }; + panes + } + + fn pane_updated_events(event_hub: &api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) + .count() + } + #[test] fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { let mut server = test_headless_server(); @@ -5431,7 +5523,6 @@ next_tab = "" agent_label: "pi".into(), state: crate::detect::AgentState::Working, message: None, - custom_status: None, seq: None, session_ref: None, }) @@ -5442,13 +5533,11 @@ next_tab = "" source: "user:pi-display".into(), agent_label: Some("pi".into()), applies_to_source: Some("herdr:pi".into()), - title: None, + title: Some("short lived".into()), display_agent: None, - custom_status: Some("short lived".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, seq: None, ttl: Some(Duration::from_millis(1)), @@ -5471,7 +5560,7 @@ next_tab = "" .terminals .get(&terminal_id) .expect("terminal") - .effective_custom_status() + .effective_title() .as_deref(), Some("short lived") ); @@ -5486,7 +5575,7 @@ next_tab = "" .terminals .get(&terminal_id) .expect("terminal") - .effective_custom_status(), + .effective_title(), None ); assert!(server @@ -5499,9 +5588,9 @@ next_tab = "" && matches!( &event.data, crate::api::schema::EventData::PaneAgentStatusChanged { - custom_status, + title, .. - } if custom_status.is_none() + } if title.is_none() ) })); } @@ -8587,7 +8676,6 @@ next_tab = "" agent: "pi".into(), state: api::schema::PaneAgentState::Idle, message: None, - custom_status: None, seq: Some(19), agent_session_id: None, agent_session_path: None, diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 2666b32f..225d609a 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -12,12 +12,10 @@ pub struct AgentMetadata { pub applies_to_source: Option<String>, pub title: Option<String>, pub display_agent: Option<String>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, pub reported_at: Instant, title_reported_at: Option<Instant>, display_agent_reported_at: Option<Instant>, - custom_status_reported_at: Option<Instant>, state_label_reported_at: HashMap<String, Instant>, pub ttl: Option<Duration>, expiry_event_pending: bool, @@ -30,11 +28,9 @@ pub struct AgentMetadataReport { pub applies_to_source: Option<String>, pub title: Option<String>, pub display_agent: Option<String>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, pub clear_title: bool, pub clear_display_agent: bool, - pub clear_custom_status: bool, pub clear_state_labels: bool, pub ttl: Option<Duration>, pub seq: Option<u64>, @@ -44,7 +40,6 @@ pub struct AgentMetadataReport { pub struct EffectivePresentation { pub title: Option<String>, pub display_agent: Option<String>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, } @@ -53,36 +48,67 @@ impl EffectivePresentation { Self { title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), } } } impl TerminalState { - fn accept_metadata_report(&mut self, source: &str, seq: Option<u64>) -> bool { + pub(crate) fn metadata_report_sequence_is_fresh(&self, source: &str, seq: Option<u64>) -> bool { + crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq) + } + + pub(crate) fn accept_metadata_report( + &mut self, + source: &str, + seq: Option<u64>, + includes_tokens: bool, + ) -> Result<bool, ()> { let Some(seq) = seq else { - return true; + return Ok(true); }; - - if self - .metadata_report_sequences - .get(source) - .is_some_and(|last_seq| seq <= *last_seq) + if !self.metadata_report_sequence_is_fresh(source, Some(seq)) { + return Ok(false); + } + if includes_tokens + && !self.metadata_token_sequence_sources.contains(source) + && self.metadata_token_sequence_sources.len() + >= crate::metadata_tokens::MAX_SEQUENCE_SOURCES { - return false; + return Err(()); } - self.metadata_report_sequences .insert(source.to_string(), seq); - true + if includes_tokens { + self.metadata_token_sequence_sources + .insert(source.to_string()); + } + Ok(true) + } + + fn metadata_guards_match( + &self, + agent_label: Option<&str>, + applies_to_source: Option<&str>, + ) -> bool { + if agent_label.is_some_and(|agent| self.effective_agent_label() != Some(agent)) { + return false; + } + !applies_to_source.is_some_and(|source| { + self.hook_authority + .as_ref() + .is_none_or(|authority| authority.source != source) + }) } pub fn set_agent_metadata( &mut self, report: AgentMetadataReport, ) -> Option<TerminalStateMutation> { - if !self.accept_metadata_report(&report.source, report.seq) { + if !matches!( + self.accept_metadata_report(&report.source, report.seq, false), + Ok(true) + ) { return None; } @@ -100,17 +126,12 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let has_set_fields = report.title.is_some() || report.display_agent.is_some() - || report.custom_status.is_some() || !report.state_labels.is_empty(); let report_source = report.source.clone(); let report_has_ttl = report.ttl.is_some(); - if report.clear_title - || report.clear_display_agent - || report.clear_custom_status - || report.clear_state_labels - { + if report.clear_title || report.clear_display_agent || report.clear_state_labels { let metadata = self .agent_metadata .entry(report.source.clone()) @@ -120,12 +141,10 @@ impl TerminalState { applies_to_source: report.applies_to_source.clone(), title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), reported_at: now, title_reported_at: None, display_agent_reported_at: None, - custom_status_reported_at: None, state_label_reported_at: HashMap::new(), ttl: report.ttl, expiry_event_pending: false, @@ -138,10 +157,6 @@ impl TerminalState { metadata.display_agent = None; metadata.display_agent_reported_at = None; } - if report.clear_custom_status { - metadata.custom_status = None; - metadata.custom_status_reported_at = None; - } if report.clear_state_labels { metadata.state_labels.clear(); metadata.state_label_reported_at.clear(); @@ -160,10 +175,6 @@ impl TerminalState { metadata.display_agent = Some(display_agent); metadata.display_agent_reported_at = Some(now); } - if let Some(custom_status) = report.custom_status { - metadata.custom_status = Some(custom_status); - metadata.custom_status_reported_at = Some(now); - } for (state, label) in report.state_labels { metadata.state_labels.insert(state.clone(), label); metadata.state_label_reported_at.insert(state, now); @@ -176,7 +187,6 @@ impl TerminalState { } else { let title_reported_at = report.title.as_ref().map(|_| now); let display_agent_reported_at = report.display_agent.as_ref().map(|_| now); - let custom_status_reported_at = report.custom_status.as_ref().map(|_| now); let state_label_reported_at = report .state_labels .keys() @@ -190,12 +200,10 @@ impl TerminalState { applies_to_source: report.applies_to_source, title: report.title, display_agent: report.display_agent, - custom_status: report.custom_status, state_labels: report.state_labels, reported_at: now, title_reported_at, display_agent_reported_at, - custom_status_reported_at, state_label_reported_at, ttl: report.ttl, expiry_event_pending: false, @@ -231,11 +239,6 @@ impl TerminalState { session_ref_changed: false, }) } - pub fn effective_custom_status(&self) -> Option<String> { - self.effective_presentation_for_state_at(self.state, Instant::now()) - .custom_status - } - pub fn effective_title(&self) -> Option<String> { self.effective_presentation_for_state_at(self.state, Instant::now()) .title @@ -337,7 +340,7 @@ impl TerminalState { fn effective_presentation_for_state_at_with_ttl( &self, - state: AgentState, + _state: AgentState, now: Instant, enforce_ttl: bool, ) -> EffectivePresentation { @@ -345,30 +348,9 @@ impl TerminalState { presentation.title = self.newest_metadata_title(now, enforce_ttl); presentation.display_agent = self.newest_metadata_display_agent(now, enforce_ttl); presentation.state_labels = self.effective_metadata_state_labels(now, enforce_ttl); - presentation.custom_status = - self.effective_custom_status_for_state_at_with_ttl(state, now, enforce_ttl); presentation } - fn effective_custom_status_for_state_at_with_ttl( - &self, - _state: AgentState, - now: Instant, - enforce_ttl: bool, - ) -> Option<String> { - if let Some(custom_status) = self.newest_metadata_custom_status(now, enforce_ttl) { - return Some(custom_status); - } - - if self.visible_blocker_overrides_hook() { - return None; - } - - self.hook_authority - .as_ref() - .and_then(|authority| authority.custom_status.clone()) - } - fn valid_agent_metadata( &self, now: Instant, @@ -393,13 +375,6 @@ impl TerminalState { .and_then(|metadata| metadata.display_agent.clone()) } - fn newest_metadata_custom_status(&self, now: Instant, enforce_ttl: bool) -> Option<String> { - self.valid_agent_metadata(now, enforce_ttl) - .filter(|metadata| metadata.custom_status.is_some()) - .max_by_key(|metadata| metadata.custom_status_reported_at) - .and_then(|metadata| metadata.custom_status.clone()) - } - fn effective_metadata_state_labels( &self, now: Instant, @@ -432,7 +407,6 @@ impl TerminalState { ) -> bool { if metadata.title.is_none() && metadata.display_agent.is_none() - && metadata.custom_status.is_none() && metadata.state_labels.is_empty() { return false; @@ -446,7 +420,6 @@ impl TerminalState { fn agent_metadata_is_visible_ignoring_ttl(&self, metadata: &AgentMetadata) -> bool { (metadata.title.is_some() || metadata.display_agent.is_some() - || metadata.custom_status.is_some() || !metadata.state_labels.is_empty()) && self.agent_metadata_matches_guards(metadata) } @@ -483,21 +456,10 @@ impl TerminalState { } fn agent_metadata_matches_guards(&self, metadata: &AgentMetadata) -> bool { - if metadata - .agent_label - .as_deref() - .is_some_and(|agent| self.effective_agent_label() != Some(agent)) - { - return false; - } - if metadata.applies_to_source.as_deref().is_some_and(|source| { - self.hook_authority - .as_ref() - .is_none_or(|authority| authority.source != source) - }) { - return false; - } - true + self.metadata_guards_match( + metadata.agent_label.as_deref(), + metadata.applies_to_source.as_deref(), + ) } } @@ -513,129 +475,39 @@ mod tests { TerminalState::new(TerminalId::alloc(), "/tmp".into()) } - fn set_metadata_custom_status( - terminal: &mut TerminalState, - source: &str, - agent_label: Option<&str>, - applies_to_source: Option<&str>, - custom_status: Option<&str>, - clear_custom_status: bool, - ) -> Option<TerminalStateMutation> { - terminal.set_agent_metadata(AgentMetadataReport { - source: source.into(), - agent_label: agent_label.map(str::to_string), - applies_to_source: applies_to_source.map(str::to_string), - title: None, - display_agent: None, - custom_status: custom_status.map(str::to_string), - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_custom_status, - clear_state_labels: false, - ttl: None, - seq: None, - }) - } - - #[test] - fn user_agent_metadata_overrides_hook_custom_status_without_changing_state() { - let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status( - "herdr:claude".into(), - "claude".into(), - AgentState::Working, - None, - Some("thinking".into()), - None, - ); - - let mutation = set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("refactor auth"), - false, - ); - - assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("refactor auth") - ); - let change = mutation.unwrap().effective_state_change.unwrap(); - assert_eq!(change.previous_state, AgentState::Working); - assert_eq!(change.state, AgentState::Working); - assert_eq!( - change.previous_presentation.custom_status.as_deref(), - Some("thinking") - ); - assert_eq!( - change.presentation.custom_status.as_deref(), - Some("refactor auth") - ); - } - - #[test] - fn user_agent_metadata_requires_matching_lifecycle_source() { - let mut terminal = test_terminal(); - terminal.set_hook_authority( - "herdr:codex".into(), - "codex".into(), - AgentState::Working, - None, - None, - ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("refactor auth"), - false, - ); - - assert_eq!(terminal.effective_custom_status(), None); - } - #[test] - fn clearing_user_agent_metadata_restores_hook_custom_status() { + fn presentation_sequences_remain_unbounded_while_token_sequences_are_bounded() { let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status( - "herdr:claude".into(), - "claude".into(), - AgentState::Working, - None, - Some("thinking".into()), - None, - ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("refactor auth"), - false, - ); - - set_metadata_custom_status(&mut terminal, "user:claude-title", None, None, None, true); - + for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES { + assert_eq!( + terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false), + Ok(true) + ); + } + for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES { + assert_eq!( + terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true), + Ok(true) + ); + } assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("thinking") + terminal.accept_metadata_report( + &format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES), + Some(2), + true, + ), + Err(()) ); } #[test] fn user_agent_metadata_overrides_presentation_fields_only() { let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status( + terminal.set_hook_authority( "herdr:claude".into(), "claude".into(), AgentState::Working, None, - Some("thinking".into()), None, ); @@ -645,11 +517,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Refactor auth".into()), display_agent: Some("Claude: auth".into()), - custom_status: Some("middleware".into()), state_labels: HashMap::from([("working".into(), "deep in the mines".into())]), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: None, @@ -660,7 +530,6 @@ mod tests { let presentation = terminal.effective_presentation(); assert_eq!(presentation.title.as_deref(), Some("Refactor auth")); assert_eq!(presentation.display_agent.as_deref(), Some("Claude: auth")); - assert_eq!(presentation.custom_status.as_deref(), Some("middleware")); assert_eq!( presentation.state_labels.get("working").map(String::as_str), Some("deep in the mines") @@ -679,11 +548,9 @@ mod tests { applies_to_source: None, title: Some("Prompt title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: None, @@ -707,27 +574,23 @@ mod tests { None, ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("first"), - false, - ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("second"), - false, - ); + for title in ["first", "second"] { + terminal.set_agent_metadata(AgentMetadataReport { + source: "user:claude-title".into(), + agent_label: Some("claude".into()), + applies_to_source: Some("herdr:claude".into()), + title: Some(title.into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + ttl: None, + seq: None, + }); + } - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("second") - ); + assert_eq!(terminal.effective_title().as_deref(), Some("second")); } #[test] @@ -746,11 +609,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Prompt title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(1), @@ -760,12 +621,10 @@ mod tests { agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), title: None, - display_agent: None, - custom_status: Some("activity".into()), + display_agent: Some("Claude activity".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(1), @@ -773,7 +632,10 @@ mod tests { let presentation = terminal.effective_presentation(); assert_eq!(presentation.title.as_deref(), Some("Prompt title")); - assert_eq!(presentation.custom_status.as_deref(), Some("activity")); + assert_eq!( + presentation.display_agent.as_deref(), + Some("Claude activity") + ); } #[test] @@ -792,26 +654,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: None, display_agent: Some("First display".into()), - custom_status: Some("old".into()), - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_custom_status: false, - clear_state_labels: false, - ttl: None, - seq: Some(1), - }); - terminal.set_agent_metadata(AgentMetadataReport { - source: "user:second".into(), - agent_label: Some("claude".into()), - applies_to_source: Some("herdr:claude".into()), - title: None, - display_agent: None, - custom_status: Some("new".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(1), @@ -822,11 +667,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Fresh title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: true, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(2), @@ -835,40 +678,6 @@ mod tests { let presentation = terminal.effective_presentation(); assert_eq!(presentation.title.as_deref(), Some("Fresh title")); assert_eq!(presentation.display_agent, None); - assert_eq!(presentation.custom_status.as_deref(), Some("new")); - } - - #[test] - fn metadata_can_set_other_fields_while_clearing_missing_source() { - let mut terminal = test_terminal(); - terminal.set_hook_authority( - "herdr:claude".into(), - "claude".into(), - AgentState::Working, - None, - None, - ); - - terminal.set_agent_metadata(AgentMetadataReport { - source: "user:status".into(), - agent_label: Some("claude".into()), - applies_to_source: Some("herdr:claude".into()), - title: None, - display_agent: None, - custom_status: Some("activity".into()), - state_labels: HashMap::new(), - clear_title: true, - clear_display_agent: false, - clear_custom_status: false, - clear_state_labels: false, - ttl: None, - seq: None, - }); - - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("activity") - ); } #[test] @@ -887,11 +696,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Old title".into()), display_agent: None, - custom_status: Some("old".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -902,27 +709,25 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Fresh title".into()), display_agent: None, - custom_status: Some("fresh".into()), state_labels: HashMap::new(), - clear_title: true, + clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: None, }); assert_eq!(terminal.next_agent_metadata_expiry(), None); - assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh")); + assert_eq!(terminal.effective_title().as_deref(), Some("Fresh title")); assert!(terminal .expire_agent_metadata_at( old_deadline + Duration::from_millis(1), old_deadline + Duration::from_millis(1) ) .is_none()); - assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh")); + assert_eq!(terminal.effective_title().as_deref(), Some("Fresh title")); } #[test] @@ -940,12 +745,10 @@ mod tests { agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), title: Some("Prompt title".into()), - display_agent: None, - custom_status: Some("old".into()), + display_agent: Some("Old display".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -958,11 +761,9 @@ mod tests { applies_to_source: None, title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, - clear_display_agent: false, - clear_custom_status: true, + clear_display_agent: true, clear_state_labels: false, ttl: None, seq: None, @@ -973,7 +774,7 @@ mod tests { terminal.effective_presentation().title.as_deref(), Some("Prompt title") ); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_presentation().display_agent, None); let mutation = terminal .expire_agent_metadata_at(old_deadline, old_deadline) @@ -996,13 +797,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Activity".into()), display_agent: None, - custom_status: Some("activity".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -1017,11 +816,11 @@ mod tests { assert_eq!(change.previous_state, AgentState::Working); assert_eq!(change.state, AgentState::Working); assert_eq!( - change.previous_presentation.custom_status.as_deref(), - Some("activity") + change.previous_presentation.title.as_deref(), + Some("Activity") ); - assert_eq!(change.presentation.custom_status, None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(change.presentation.title, None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1038,13 +837,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Stale".into()), display_agent: None, - custom_status: Some("stale".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -1055,7 +852,7 @@ mod tests { .and_then(|metadata| terminal.agent_metadata_expiry(metadata)) .unwrap(); assert_eq!(terminal.next_agent_metadata_expiry(), None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_title(), None); terminal.set_hook_authority( "herdr:claude".into(), @@ -1071,7 +868,7 @@ mod tests { assert!(mutation.is_none()); assert!(!terminal.agent_metadata.contains_key("user:status")); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1090,11 +887,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("First".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -1106,11 +901,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: None, display_agent: Some("Second".into()), - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(2)), seq: None, @@ -1149,13 +942,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Instant".into()), display_agent: None, - custom_status: Some("instant".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::ZERO), seq: None, @@ -1170,11 +961,11 @@ mod tests { let change = mutation.effective_state_change.unwrap(); assert_eq!( - change.previous_presentation.custom_status.as_deref(), - Some("instant") + change.previous_presentation.title.as_deref(), + Some("Instant") ); - assert_eq!(change.presentation.custom_status, None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(change.presentation.title, None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1191,13 +982,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Instant".into()), display_agent: None, - custom_status: Some("instant".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::ZERO), seq: None, @@ -1220,7 +1009,7 @@ mod tests { ); assert_eq!(terminal.next_agent_metadata_expiry(), None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1239,11 +1028,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Expired title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::ZERO), seq: None, @@ -1256,11 +1043,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: None, display_agent: Some("Fresh display".into()), - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: true, clear_state_labels: false, ttl: None, seq: None, diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 03432a56..a67b7bae 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -2,6 +2,7 @@ mod id; mod runtime; mod runtime_registry; pub mod state; +mod title; pub use id::TerminalId; pub use runtime::TerminalRuntime; @@ -10,3 +11,4 @@ pub use state::{ AgentMetadataReport, EffectivePresentation, EffectiveStateChange, TerminalState, TerminalStateMutation, }; +pub(crate) use title::stripped_terminal_title; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 1ff799b8..29d19140 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -307,6 +307,10 @@ impl TerminalRuntime { self.0.detection_text() } + pub fn terminal_title(&self) -> Option<String> { + self.0.terminal_title() + } + pub fn agent_osc_title(&self) -> String { self.0.agent_osc_title() } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 2b17404a..632558fd 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -22,7 +22,6 @@ pub struct HookAuthority { pub agent_label: String, pub state: AgentState, pub message: Option<String>, - pub custom_status: Option<String>, pub reported_at: Instant, pub session_ref: Option<crate::agent_resume::AgentSessionRef>, } @@ -59,6 +58,12 @@ pub struct EffectiveStateChange { pub presentation: EffectivePresentation, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct TerminalTitleChange { + pub(crate) raw_changed: bool, + pub(crate) stripped_changed: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TerminalStateMutation { pub effective_state_change: Option<EffectiveStateChange>, @@ -79,13 +84,16 @@ pub struct TerminalState { fallback_observed_at: Option<Instant>, pub hook_authority: Option<HookAuthority>, pub agent_metadata: HashMap<String, AgentMetadata>, + pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, + pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>, metadata_report_sequences: HashMap<String, u64>, + metadata_token_sequence_sources: std::collections::HashSet<String>, pub state: AgentState, pub last_agent_state_change_seq: Option<u64>, pub revision: u64, @@ -106,13 +114,16 @@ impl TerminalState { fallback_observed_at: None, hook_authority: None, agent_metadata: HashMap::new(), + metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, + terminal_title: None, manual_label: None, agent_name: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), metadata_report_sequences: HashMap::new(), + metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, revision: 0, @@ -123,6 +134,28 @@ impl TerminalState { } } + pub(crate) fn terminal_title_stripped(&self) -> Option<String> { + self.terminal_title + .as_deref() + .and_then(super::stripped_terminal_title) + } + + pub(crate) fn set_terminal_title(&mut self, title: Option<String>) -> TerminalTitleChange { + if self.terminal_title == title { + return TerminalTitleChange::default(); + } + let previous_stripped = self.terminal_title_stripped(); + self.terminal_title = title; + let stripped_changed = previous_stripped != self.terminal_title_stripped(); + if stripped_changed { + self.revision = self.revision.wrapping_add(1); + } + TerminalTitleChange { + raw_changed: true, + stripped_changed, + } + } + pub fn with_launch_argv(mut self, argv: Vec<String>) -> Self { self.launch_argv = Some(argv); self @@ -331,25 +364,11 @@ impl TerminalState { message: Option<String>, seq: Option<u64>, ) -> Option<EffectiveStateChange> { - self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) - } - - #[cfg(test)] - pub fn set_hook_authority_with_custom_status( - &mut self, - source: String, - agent_label: String, - state: AgentState, - message: Option<String>, - custom_status: Option<String>, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - self.set_hook_authority_with_custom_status_at( + self.set_hook_authority_at( source, agent_label, state, message, - custom_status, None, seq, Instant::now(), @@ -363,29 +382,26 @@ impl TerminalState { agent_label: String, state: AgentState, message: Option<String>, - custom_status: Option<String>, session_ref: Option<crate::agent_resume::AgentSessionRef>, seq: Option<u64>, ) -> Option<TerminalStateMutation> { - self.set_hook_authority_with_custom_status_at( + self.set_hook_authority_at( source, agent_label, state, message, - custom_status, session_ref, seq, Instant::now(), ) } - pub fn set_hook_authority_with_custom_status_at( + pub fn set_hook_authority_at( &mut self, source: String, agent_label: String, state: AgentState, message: Option<String>, - custom_status: Option<String>, session_ref: Option<crate::agent_resume::AgentSessionRef>, seq: Option<u64>, now: Instant, @@ -476,7 +492,6 @@ impl TerminalState { agent_label, state, message, - custom_status, reported_at: now, session_ref, }); @@ -1524,7 +1539,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(10), ); @@ -1548,7 +1562,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(12), ); @@ -1576,7 +1589,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(10), ); @@ -1593,7 +1605,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(session_b.clone()), Some(12), ); @@ -1610,7 +1621,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(14), ); @@ -1628,7 +1638,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(session_b), Some(15), ); @@ -1681,7 +1690,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(10), ); @@ -1698,7 +1706,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session), Some(12), ); @@ -1725,7 +1732,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(10), ); @@ -1754,7 +1760,6 @@ mod tests { "omp".into(), AgentState::Blocked, Some("waiting".into()), - None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(12), ); @@ -1771,7 +1776,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(13), ); @@ -1785,13 +1789,12 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(10), now, ); @@ -1813,13 +1816,12 @@ mod tests { AgentState::Working ); - let stale = terminal.set_hook_authority_with_custom_status_at( + let stale = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(9), now + Duration::from_millis(2), ); @@ -1833,13 +1835,12 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(20), now, ); @@ -1853,12 +1854,11 @@ mod tests { true, now + Duration::from_millis(1), ); - let late = terminal.set_hook_authority_with_custom_status_at( + let late = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - Some("late".into()), None, Some(21), now + Duration::from_millis(2), @@ -1881,7 +1881,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); @@ -1900,7 +1899,6 @@ mod tests { "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_path), Some(21), ); @@ -1946,7 +1944,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); @@ -1957,7 +1954,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(22), ); @@ -1976,7 +1972,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), Some(20), ); @@ -1987,7 +1982,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), Some(22), ); @@ -2006,7 +2000,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), Some(1000), ); @@ -2017,7 +2010,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), Some(1500), ); @@ -2039,7 +2031,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(1000), ); @@ -2050,7 +2041,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_b), Some(1500), ); @@ -2062,7 +2052,6 @@ mod tests { "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_a), Some(2500), ); @@ -2071,7 +2060,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_c), Some(2500), ); @@ -2091,7 +2079,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")), Some(20), ); @@ -2101,7 +2088,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")), Some(21), ); @@ -2219,7 +2205,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(1000), ); @@ -2259,7 +2244,6 @@ mod tests { "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(old_session), Some(500), ); @@ -2268,7 +2252,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session), Some(500), ); @@ -2286,12 +2269,11 @@ mod tests { let new_session = test_session_path("new-after-process-exit.jsonl"); let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(1000), now, @@ -2306,12 +2288,11 @@ mod tests { now + Duration::from_millis(1), ); - let early_new = terminal.set_hook_authority_with_custom_status_at( + let early_new = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(500), now + Duration::from_millis(2), @@ -2338,12 +2319,11 @@ mod tests { false, now + Duration::from_millis(4), ); - let fresh_new = terminal.set_hook_authority_with_custom_status_at( + let fresh_new = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session), Some(500), now + Duration::from_millis(5), @@ -2360,12 +2340,11 @@ mod tests { let old_session = test_session_path("old-before-nosession-process-exit.jsonl"); let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(1000), now, @@ -2380,13 +2359,12 @@ mod tests { now + Duration::from_millis(1), ); - let early_without_session = terminal.set_hook_authority_with_custom_status_at( + let early_without_session = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(500), now + Duration::from_millis(2), ); @@ -2412,13 +2390,12 @@ mod tests { false, now + Duration::from_millis(4), ); - let fresh_without_session = terminal.set_hook_authority_with_custom_status_at( + let fresh_without_session = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(500), now + Duration::from_millis(5), ); @@ -2436,12 +2413,11 @@ mod tests { let session_c = test_session_path("generation-c.jsonl"); let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(1000), now, @@ -2474,12 +2450,11 @@ mod tests { false, now + Duration::from_millis(3), ); - let generation_b = terminal.set_hook_authority_with_custom_status_at( + let generation_b = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_b), Some(500), now + Duration::from_millis(4), @@ -2514,22 +2489,20 @@ mod tests { now + Duration::from_millis(7), ); - let late_generation_a = terminal.set_hook_authority_with_custom_status_at( + let late_generation_a = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_a), Some(250), now + Duration::from_millis(8), ); - let generation_c = terminal.set_hook_authority_with_custom_status_at( + let generation_c = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_c), Some(250), now + Duration::from_millis(9), @@ -2596,7 +2569,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("fresh-session"), Some(22), ); @@ -2638,7 +2610,6 @@ mod tests { "mastracode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("mastracode-new"), Some(20), ) @@ -2669,7 +2640,6 @@ mod tests { agent_label.into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id(&old_session), Some(20), ); @@ -2682,7 +2652,6 @@ mod tests { agent_label.into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id(&old_session), Some(22), ); @@ -2695,7 +2664,6 @@ mod tests { agent_label.into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id(&new_session), Some(23), ); @@ -2709,12 +2677,11 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:omp".into(), "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(1000), now, @@ -2734,7 +2701,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(500), ); @@ -2764,7 +2730,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("omp-new"), Some(500), ); @@ -2898,41 +2863,15 @@ mod tests { } #[test] - fn visible_blocker_suppresses_stale_hook_custom_status() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); - terminal.set_hook_authority_with_custom_status( - "herdr:codex".into(), - "codex".into(), - AgentState::Working, - None, - Some("planning".into()), - None, - ); - - terminal.set_detected_state_with_visible_blocker( - Some(Agent::Codex), - AgentState::Blocked, - true, - false, - false, - ); - - assert_eq!(terminal.state, AgentState::Blocked); - assert_eq!(terminal.effective_custom_status(), None); - } - - #[test] fn fallback_idle_does_not_override_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Working, None, - Some("thinking".into()), None, None, now, @@ -2950,10 +2889,6 @@ mod tests { assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("thinking") - ); } #[test] @@ -2961,12 +2896,11 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, - Some("thinking".into()), None, None, now, @@ -2983,10 +2917,6 @@ mod tests { assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("thinking") - ); } #[test] @@ -2994,14 +2924,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Idle, None, None, None, - None, now, ); @@ -3025,14 +2954,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:hermes".into(), "hermes".into(), AgentState::Idle, None, None, None, - None, now, ); @@ -3056,14 +2984,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:kilo".into(), "kilo".into(), AgentState::Idle, None, None, None, - None, now, ); @@ -3096,14 +3023,13 @@ mod tests { now, ); - let change = terminal.set_hook_authority_with_custom_status_at( + let change = terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Idle, None, None, None, - None, now + Duration::from_millis(100), ); @@ -3131,12 +3057,11 @@ mod tests { false, now, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Blocked, None, - Some("permission".into()), None, None, now + Duration::from_millis(1201), @@ -3156,10 +3081,6 @@ mod tests { assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Blocked); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("permission") - ); assert!(change.effective_state_change.is_none()); } @@ -3271,14 +3192,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, None, - None, now, ); @@ -3379,13 +3299,12 @@ mod tests { false, observed, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Working, None, None, - None, Some(1), observed + Duration::from_secs(1), ); @@ -3416,22 +3335,20 @@ mod tests { false, observed, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, - None, Some(1), observed, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, - Some("new turn".into()), None, Some(2), observed + Duration::from_secs(1), @@ -3448,7 +3365,7 @@ mod tests { ); let authority = terminal.hook_authority.as_ref().expect("hook authority"); - assert_eq!(authority.custom_status.as_deref(), Some("new turn")); + assert_eq!(authority.reported_at, observed + Duration::from_secs(1)); assert_eq!(terminal.state, AgentState::Working); assert_eq!(terminal.effective_agent_label(), Some("codex")); } @@ -3530,7 +3447,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ) @@ -3560,7 +3476,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); @@ -3570,7 +3485,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session_path), Some(19), ); @@ -3595,7 +3509,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); @@ -3607,7 +3520,6 @@ mod tests { AgentState::Working, None, None, - None, Some(21), ) .expect("accepted report"); @@ -3638,7 +3550,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), Some(20), ) @@ -4019,12 +3930,11 @@ mod tests { let mut terminal = test_terminal(); let now = std::time::Instant::now(); terminal - .set_hook_authority_with_custom_status_at( + .set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), now + Duration::from_millis(1), @@ -4066,7 +3976,6 @@ mod tests { "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(22), ); @@ -4078,7 +3987,6 @@ mod tests { "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-new-session"), Some(23), ); @@ -4102,7 +4010,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"), Some(21), ); @@ -4152,7 +4059,6 @@ mod tests { "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), ) @@ -4164,7 +4070,6 @@ mod tests { "opencode".into(), AgentState::Blocked, Some("needs approval".into()), - None, crate::agent_resume::AgentSessionRef::id("nested-session"), Some(21), ) @@ -4224,7 +4129,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); @@ -4246,7 +4150,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); @@ -4419,7 +4322,6 @@ mod tests { "claude".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ); @@ -4448,7 +4350,6 @@ mod tests { "hermes".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("hermes-session"), Some(20), ); diff --git a/src/terminal/title.rs b/src/terminal/title.rs new file mode 100644 index 00000000..32ba19c4 --- /dev/null +++ b/src/terminal/title.rs @@ -0,0 +1,64 @@ +const CLAUDE_ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽"; + +pub(crate) fn stripped_terminal_title(title: &str) -> Option<String> { + let title = title.trim(); + if title.is_empty() { + return None; + } + + let mut chars = title.char_indices(); + let (_, first) = chars.next()?; + let after_first = &title[first.len_utf8()..]; + let recognized = + matches!(first, '\u{2800}'..='\u{28ff}') || CLAUDE_ACTIVITY_GLYPHS.contains(first); + let stripped = if recognized + && (after_first.is_empty() || after_first.chars().next().is_some_and(char::is_whitespace)) + { + after_first.trim() + } else { + title + }; + + (!stripped.is_empty()).then(|| stripped.to_string()) +} + +#[cfg(test)] +mod tests { + use super::stripped_terminal_title; + + #[test] + fn strips_one_recognized_leading_activity_glyph() { + for title in ["⠋ task", "✳ task", " ⠙ task ", "✢ task", "✻ task"] { + assert_eq!(stripped_terminal_title(title).as_deref(), Some("task")); + } + assert_eq!( + stripped_terminal_title("⠋ ⠙ task").as_deref(), + Some("⠙ task") + ); + } + + #[test] + fn preserves_unrecognized_or_unbounded_symbols() { + for (title, expected) in [ + ("★task", "★task"), + ("★ production", "★ production"), + ("✨ task", "✨ task"), + ("☼ status", "☼ status"), + ("@ task", "@ task"), + ("task ⠋ detail", "task ⠋ detail"), + ("[prod] task", "[prod] task"), + ] { + assert_eq!(stripped_terminal_title(title).as_deref(), Some(expected)); + } + } + + #[test] + fn preserves_unicode_text_and_elides_empty_results() { + assert_eq!( + stripped_terminal_title(" ⠋ 修复🙂标题 ").as_deref(), + Some("修复🙂标题") + ); + assert_eq!(stripped_terminal_title(" "), None); + assert_eq!(stripped_terminal_title("⠋ "), None); + } +} @@ -69,13 +69,14 @@ pub(crate) use self::{ SETTINGS_POPUP_WIDTH, }, sidebar::{ - agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics, - agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections, - collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, - expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect, - workspace_drop_indicator_row, workspace_list_entries, workspace_list_entries_expanded, - workspace_list_rect, workspace_list_scroll_metrics, workspace_list_scrollbar_rect, - workspace_parent_group_state, WorkspaceListEntry, + agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries, + agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect, + agent_panel_toggle_rect, collapsed_sidebar_sections, collapsed_sidebar_toggle_rect, + compute_workspace_card_areas, expanded_sidebar_sections, expanded_sidebar_toggle_rect, + normalized_workspace_scroll, sidebar_section_divider_rect, workspace_drop_indicator_row, + workspace_list_entries, workspace_list_entries_expanded, workspace_list_rect, + workspace_list_scroll_metrics, workspace_list_scrollbar_rect, workspace_parent_group_state, + WorkspaceListEntry, }, }; pub(crate) use self::{ diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index f1aae316..d1e6ac61 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -731,10 +731,6 @@ fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { if let Some(agent_label) = entry.agent_label.as_deref() { parts.push(agent_label.to_string()); } - if let Some(custom_status) = entry.custom_status.as_deref() { - parts.push(custom_status.to_string()); - } - format!(" {}", parts.join(" · ")) } @@ -1140,12 +1136,16 @@ mod tests { pane_id: PaneId::from_raw(1), primary_label: "herdr".into(), primary_tab_label: primary_tab_label.map(str::to_string), + pane_label: None, + terminal_title: None, + terminal_title_stripped: None, agent_label: agent_label.map(str::to_string), + agent: agent_label.and_then(crate::detect::parse_agent_label), state: AgentState::Idle, seen: true, last_agent_state_change_seq: None, - custom_status: None, state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), } } diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs index 8bfe6531..0ad336f6 100644 --- a/src/ui/navigator.rs +++ b/src/ui/navigator.rs @@ -427,9 +427,6 @@ fn pane_detail( } else { parts.push("shell".to_string()); } - if let Some(status) = terminal.effective_custom_status() { - parts.push(status.to_string()); - } } } parts.join(" · ") diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index ca23af0c..18d13b26 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,3 +1,5 @@ +mod tokens; + use ratatui::{ layout::{Alignment, Rect}, style::{Modifier, Style}, @@ -6,6 +8,7 @@ use ratatui::{ Frame, }; +use self::tokens::{ResolvedToken, SpaceTokenContext}; use super::scrollbar::{render_scrollbar, should_show_scrollbar}; use super::status::{agent_icon, state_dot, state_label, state_label_color}; use super::text::{display_width, display_width_u16, truncate_end}; @@ -23,12 +26,16 @@ pub(crate) struct AgentPanelEntry { pub pane_id: crate::layout::PaneId, pub primary_label: String, pub primary_tab_label: Option<String>, + pub pane_label: Option<String>, + pub terminal_title: Option<String>, + pub terminal_title_stripped: Option<String>, pub agent_label: Option<String>, + pub agent: Option<crate::detect::Agent>, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option<u64>, - pub custom_status: Option<String>, pub state_labels: std::collections::HashMap<String, String>, + pub tokens: std::collections::HashMap<String, String>, } fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { @@ -131,12 +138,16 @@ fn agent_panel_entries_with_runtimes( pane_id: detail.pane_id, primary_label: workspace_label.clone(), primary_tab_label: multi_tab.then_some(detail.tab_label), + pane_label: detail.pane_label, + terminal_title: detail.terminal_title, + terminal_title_stripped: detail.terminal_title_stripped, agent_label: Some(detail.agent_label), + agent: detail.agent, state: detail.state, seen: detail.seen, last_agent_state_change_seq: detail.last_agent_state_change_seq, - custom_status: detail.custom_status, state_labels: detail.state_labels, + tokens: detail.tokens, }) }) .collect(); @@ -163,56 +174,48 @@ pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static } } -fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String { - let Some(tab_label) = entry.primary_tab_label.as_deref() else { - return truncate_end(&entry.primary_label, max_width); +fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indented: bool) -> u16 { + let (state, seen) = ws.aggregate_state(&app.terminals); + let label = if indented { + grouped_child_display_label( + &ws.display_name(), + ws.branch().as_deref(), + ws.custom_name.is_some(), + ) + } else { + ws.display_name() }; - - let separator = " · "; - let separator_width = display_width(separator); - if max_width <= separator_width + 2 { - return truncate_end( - &format!("{}{}{}", entry.primary_label, separator, tab_label), - max_width, - ); - } - - let available = max_width.saturating_sub(separator_width); - let min_tab = 4.min(available.saturating_sub(1)).max(1); - let preferred_workspace = ((available * 2) / 3).max(1); - let mut workspace_budget = preferred_workspace - .min(available.saturating_sub(min_tab)) - .max(1); - let mut tab_budget = available.saturating_sub(workspace_budget); - - let workspace_len = display_width(&entry.primary_label); - let tab_len = display_width(tab_label); - - if workspace_len < workspace_budget { - let spare = workspace_budget - workspace_len; - workspace_budget = workspace_len; - tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget)); - } - if tab_len < tab_budget { - let spare = tab_budget - tab_len; - tab_budget = tab_len; - workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget)); - } - - format!( - "{}{}{}", - truncate_end(&entry.primary_label, workspace_budget), - separator, - truncate_end(tab_label, tab_budget) + let token_values = ws.metadata_tokens.values(); + tokens::space_rows( + &app.sidebar_spaces, + SpaceTokenContext { + workspace: &label, + branch: ws.branch().as_deref(), + state_text: state_label(state, seen), + ahead_behind: ws.git_ahead_behind(), + tokens: &token_values, + suppress_git_details: indented, + }, ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 } -fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 { - if ws.branch().is_some() { - 2 - } else { - 1 - } +fn workspace_row_height_in_body( + app: &AppState, + workspace: &crate::workspace::Workspace, + indented: bool, + body_height: u16, +) -> u16 { + workspace_row_height(app, workspace, indented).min(body_height) +} + +fn workspace_entry_gap(entries: &[WorkspaceListEntry], entry_idx: usize, indented: bool) -> u16 { + u16::from( + entry_idx + 1 < entries.len() + && !(indented && next_entry_is_indented_workspace(entries, entry_idx)), + ) } fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { @@ -294,11 +297,10 @@ pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: return requested; } - let entry_count = workspace_list_entries(app).len(); - if entry_count == 0 { + if workspace_list_entries(app).is_empty() { 0 } else { - requested.min(entry_count.saturating_sub(1)) + requested.min(workspace_list_bottom_start(app, ws_area)) } } @@ -440,47 +442,62 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us let mut visible = 0usize; let entries = workspace_list_entries(app); for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - let needed = match entry { + let (row_height, gap) = match entry { WorkspaceListEntry::Workspace { ws_idx, indented } => { let Some(ws) = app.workspaces.get(*ws_idx) else { continue; }; - let row_height = if *indented { - 1 - } else { - workspace_row_height(ws) - }; - let gap = u16::from( - !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)), - ); - row_height.saturating_add(gap) + ( + workspace_row_height_in_body(app, ws, *indented, body.height), + workspace_entry_gap(&entries, entry_idx, *indented), + ) } }; - if used_rows.saturating_add(needed) > body.height { + if used_rows.saturating_add(row_height) > body.height { break; } - used_rows = used_rows.saturating_add(needed); + used_rows = used_rows.saturating_add(row_height); visible += 1; + if gap > 0 && used_rows < body.height { + used_rows = used_rows.saturating_add(1); + } } visible } +fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize { + let body = workspace_list_body_rect(area, false); + let entries = workspace_list_entries(app); + let mut used_rows = 0u16; + let mut start = entries.len(); + for (entry_idx, entry) in entries.iter().enumerate().rev() { + let WorkspaceListEntry::Workspace { ws_idx, indented } = entry; + let Some(workspace) = app.workspaces.get(*ws_idx) else { + continue; + }; + let gap = workspace_entry_gap(&entries, entry_idx, *indented); + let needed = workspace_row_height_in_body(app, workspace, *indented, body.height) + .saturating_add(gap); + if used_rows.saturating_add(needed) > body.height { + break; + } + used_rows = used_rows.saturating_add(needed); + start = entry_idx; + } + start.min(entries.len().saturating_sub(1)) +} + pub(crate) fn workspace_list_scroll_metrics( app: &AppState, area: Rect, ) -> crate::pane::ScrollMetrics { - let entries = workspace_list_entries(app); - let total_rows = entries.len(); - let scroll = app.workspace_scroll.min(total_rows.saturating_sub(1)); + let max_scroll = workspace_list_bottom_start(app, area); + let scroll = app.workspace_scroll.min(max_scroll); let viewport_rows = workspace_list_visible_count(app, area, scroll); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - let offset_from_bottom = total_rows - .saturating_sub(scroll) - .saturating_sub(viewport_rows); crate::pane::ScrollMetrics { - offset_from_bottom, - max_offset_from_bottom, + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, viewport_rows, } } @@ -507,16 +524,41 @@ pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect { Rect::new(area.x, body_y, body_width, body_height) } -fn agent_panel_visible_count(area: Rect) -> usize { +fn resolved_agent_rows(app: &AppState, entry: &AgentPanelEntry) -> Vec<Vec<ResolvedToken>> { + let label = entry + .state_labels + .get(agent_panel_status_key(entry.state, entry.seen)) + .map(String::as_str) + .unwrap_or_else(|| state_label(entry.state, entry.seen)); + tokens::agent_rows(&app.sidebar_agents, entry, label) +} + +pub(crate) fn agent_entry_height_in_body( + app: &AppState, + entry: &AgentPanelEntry, + body_height: u16, +) -> u16 { + (resolved_agent_rows(app, entry) + .len() + .max(1) + .min(u16::MAX as usize) as u16) + .min(body_height) +} + +fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize { let body = agent_panel_body_rect(area, false); - if body.width == 0 || body.height < 2 { + if body.width == 0 || body.height == 0 { return 0; } let mut used_rows = 0u16; let mut visible = 0usize; - while used_rows.saturating_add(2) <= body.height { - used_rows = used_rows.saturating_add(2); + for entry in agent_panel_entries(app).iter().skip(scroll) { + let height = agent_entry_height_in_body(app, entry, body.height); + if used_rows.saturating_add(height) > body.height { + break; + } + used_rows = used_rows.saturating_add(height); visible += 1; if used_rows < body.height { used_rows = used_rows.saturating_add(1); @@ -525,17 +567,52 @@ fn agent_panel_visible_count(area: Rect) -> usize { visible } +fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize { + let body = agent_panel_body_rect(area, false); + let entries = agent_panel_entries(app); + let mut used_rows = 0u16; + let mut start = entries.len(); + for (index, entry) in entries.iter().enumerate().rev() { + let gap = u16::from(index + 1 < entries.len()); + let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap); + if used_rows.saturating_add(needed) > body.height { + break; + } + used_rows = used_rows.saturating_add(needed); + start = index; + } + start.min(entries.len().saturating_sub(1)) +} + +pub(crate) fn agent_panel_scroll_for_target( + app: &AppState, + area: Rect, + current_scroll: usize, + target: usize, +) -> usize { + let max_scroll = agent_panel_bottom_start(app, area); + if target < current_scroll { + return target.min(max_scroll); + } + let mut scroll = current_scroll.min(max_scroll); + while scroll < target { + let visible = agent_panel_visible_count_from(app, area, scroll); + if visible > 0 && target < scroll.saturating_add(visible) { + break; + } + scroll += 1; + } + scroll.min(max_scroll) +} + pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics { - let viewport_rows = agent_panel_visible_count(area); - let total_rows = agent_panel_entries(app).len(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - let offset_from_bottom = total_rows - .saturating_sub(app.agent_panel_scroll) - .saturating_sub(viewport_rows); + let max_scroll = agent_panel_bottom_start(app, area); + let scroll = app.agent_panel_scroll.min(max_scroll); + let viewport_rows = agent_panel_visible_count_from(app, area, scroll); crate::pane::ScrollMetrics { - offset_from_bottom, - max_offset_from_bottom, + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, viewport_rows, } } @@ -579,15 +656,9 @@ pub(crate) fn compute_workspace_list_areas( let Some(ws) = app.workspaces.get(*ws_idx) else { continue; }; - let row_height = if *indented { - 1 - } else { - workspace_row_height(ws) - }; - let gap = u16::from( - !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)), - ); - if row_y.saturating_add(row_height).saturating_add(gap) > body_bottom { + let row_height = workspace_row_height_in_body(app, ws, *indented, body.height); + let gap = workspace_entry_gap(&entries, entry_idx, *indented); + if row_y.saturating_add(row_height) > body_bottom { break; } cards.push(crate::app::state::WorkspaceCardArea { @@ -595,7 +666,10 @@ pub(crate) fn compute_workspace_list_areas( rect: Rect::new(body.x, row_y, body.width, row_height), indented: *indented, }); - row_y = row_y.saturating_add(row_height + gap); + row_y = row_y.saturating_add(row_height); + if gap > 0 && row_y < body_bottom { + row_y = row_y.saturating_add(1); + } } } } @@ -797,6 +871,179 @@ pub(super) fn render_sidebar( render_sidebar_toggle(app, frame, area, false, p); } +fn resolved_token_spans( + resolved: &[ResolvedToken], + state_icon: (&str, Style), + state_text_style: Style, + workspace_style: Style, + secondary_style: Style, + custom_style: Style, + p: &Palette, + max_width: usize, +) -> Vec<Span<'static>> { + let fixed_widths = resolved + .iter() + .map(|token| match token { + ResolvedToken::StateIcon => display_width(state_icon.0), + ResolvedToken::GitStatus { ahead, behind } => { + usize::from(*ahead > 0) * display_width(&format!("↑{ahead}")) + + usize::from(*behind > 0) * display_width(&format!("↓{behind}")) + + usize::from(*ahead > 0 && *behind > 0) + } + _ => 0, + }) + .collect::<Vec<_>>(); + let flexible_widths = resolved + .iter() + .map(|token| match token { + ResolvedToken::StateText(text) + | ResolvedToken::Workspace(text) + | ResolvedToken::Tab(text) + | ResolvedToken::Pane(text) + | ResolvedToken::Agent(text) + | ResolvedToken::TerminalTitle(text) + | ResolvedToken::Branch(text) + | ResolvedToken::Custom(text) => display_width(text), + _ => 0, + }) + .collect::<Vec<_>>(); + let minimum_width = |active: &[bool]| { + let indices = active + .iter() + .enumerate() + .filter_map(|(index, active)| active.then_some(index)) + .collect::<Vec<_>>(); + let content = indices + .iter() + .map(|index| fixed_widths[*index] + usize::from(flexible_widths[*index] > 0)) + .sum::<usize>(); + let separators = indices + .windows(2) + .map(|pair| display_width(tokens::separator(&resolved[pair[0]], &resolved[pair[1]]))) + .sum::<usize>(); + content + separators + }; + let mut active = resolved.iter().map(|_| true).collect::<Vec<_>>(); + if minimum_width(&active) > max_width { + for (index, width) in flexible_widths.iter().enumerate() { + if *width > 0 { + active[index] = false; + } + } + for index in (0..resolved.len()).rev() { + if flexible_widths[index] == 0 { + continue; + } + active[index] = true; + if minimum_width(&active) > max_width { + active[index] = false; + } + } + } + let visible_indices = active + .iter() + .enumerate() + .filter_map(|(index, active)| active.then_some(index)) + .collect::<Vec<_>>(); + let separator_width = visible_indices + .windows(2) + .map(|pair| display_width(tokens::separator(&resolved[pair[0]], &resolved[pair[1]]))) + .sum::<usize>(); + let fixed_width = visible_indices + .iter() + .map(|index| fixed_widths[*index]) + .sum::<usize>(); + let mut budgets = flexible_widths + .iter() + .enumerate() + .map(|(index, width)| usize::from(active[index] && *width > 0)) + .collect::<Vec<_>>(); + let minimum = budgets.iter().sum::<usize>(); + let mut remaining = max_width + .saturating_sub(separator_width + fixed_width) + .saturating_sub(minimum); + while remaining > 0 { + let mut grew = false; + for (budget, width) in budgets.iter_mut().zip(&flexible_widths) { + if *budget > 0 && *budget < *width { + *budget += 1; + remaining -= 1; + grew = true; + if remaining == 0 { + break; + } + } + } + if !grew { + break; + } + } + let mut spans = Vec::new(); + for (position, index) in visible_indices.iter().copied().enumerate() { + let token = &resolved[index]; + if position > 0 { + let previous = &resolved[visible_indices[position - 1]]; + spans.push(Span::styled( + tokens::separator(previous, token), + Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), + )); + } + match token { + ResolvedToken::StateIcon => { + spans.push(Span::styled(state_icon.0.to_string(), state_icon.1)); + } + ResolvedToken::StateText(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + state_text_style, + )); + } + ResolvedToken::Workspace(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + workspace_style, + )); + } + ResolvedToken::Tab(text) | ResolvedToken::Pane(text) | ResolvedToken::Agent(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + secondary_style, + )); + } + ResolvedToken::Branch(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + secondary_style, + )); + } + ResolvedToken::GitStatus { ahead, behind } => { + if *ahead > 0 { + spans.push(Span::styled( + format!("↑{ahead}"), + Style::default().fg(p.green), + )); + } + if *ahead > 0 && *behind > 0 { + spans.push(Span::raw(" ")); + } + if *behind > 0 { + spans.push(Span::styled( + format!("↓{behind}"), + Style::default().fg(p.red), + )); + } + } + ResolvedToken::TerminalTitle(text) | ResolvedToken::Custom(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + custom_style, + )); + } + } + } + spans +} + fn render_workspace_list( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -870,94 +1117,89 @@ fn render_workspace_list( Style::default().fg(p.subtext0) }; - let (icon, icon_style) = state_dot(agg_state, agg_seen, p); let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let mut line1 = Vec::new(); - let mut show_workspace_icon = true; - if card.indented { - line1.push(Span::styled(" ", Style::default())); - } else if let Some((key, collapsed)) = workspace_parent_group_state(app, i) { - let icon = if collapsed { "▸" } else { "▾" }; - let (state_icon, state_style) = if collapsed { - let (state, seen) = space_aggregate_state(app, &key); - state_dot(state, seen, p) - } else { - (icon, Style::default().fg(p.accent)) - }; - line1.push(Span::styled(icon, Style::default().fg(p.accent))); - if collapsed { - line1.push(Span::styled(" ", Style::default())); - line1.push(Span::styled(state_icon, state_style)); - show_workspace_icon = false; - } - line1.push(Span::styled(" ", Style::default())); + let display_label = if card.indented { + grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some()) } else { - line1.push(Span::styled(" ", Style::default())); - } - if show_workspace_icon { - line1.push(Span::styled(icon, icon_style)); - line1.push(Span::styled(" ", Style::default())); - } - if card.indented { - let display_label = grouped_child_display_label( - &label, - ws.branch().as_deref(), - ws.custom_name.is_some(), - ); - line1.push(Span::styled(display_label, name_style)); + label + }; + let parent_group = (!card.indented) + .then(|| workspace_parent_group_state(app, i)) + .flatten(); + let (display_state, display_seen) = parent_group + .as_ref() + .filter(|(_, collapsed)| *collapsed) + .map(|(key, _)| space_aggregate_state(app, key)) + .unwrap_or((agg_state, agg_seen)); + let state_icon = state_dot(display_state, display_seen, p); + let state_text_style = Style::default() + .fg(state_label_color(display_state, display_seen, p)) + .add_modifier(Modifier::DIM); + let branch_style = Style::default().fg(if selected || is_active { + p.mauve } else { - line1.push(Span::styled(label, name_style)); - } - - frame.render_widget( - Paragraph::new(Line::from(line1)), - Rect::new(card.rect.x, row_y, card.rect.width, 1), + p.overlay0 + }); + let token_values = ws.metadata_tokens.values(); + let rows = tokens::space_rows( + &app.sidebar_spaces, + SpaceTokenContext { + workspace: &display_label, + branch: ws.branch().as_deref(), + state_text: state_label(display_state, display_seen), + ahead_behind: ws.git_ahead_behind(), + tokens: &token_values, + suppress_git_details: card.indented, + }, ); - if row_height > 1 && row_y + 1 < list_bottom { - if let Some(branch) = ws.branch() { - let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| { - let mut parts = Vec::new(); - if ahead > 0 { - parts.push((format!("↑{}", ahead), p.green)); - } - if behind > 0 { - parts.push((format!("↓{}", behind), p.red)); - } - (!parts.is_empty()).then_some(parts) - }); - let reserved = upstream_label - .as_ref() - .map(|parts| { - parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len() - }) - .unwrap_or(0); - let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved); - let branch_display = truncate_end(&branch, max_branch_len); - let branch_color = if selected || is_active { - p.mauve + for (row_index, resolved) in rows.iter().enumerate() { + if row_index as u16 >= row_height || row_y + row_index as u16 >= list_bottom { + break; + } + let mut spans = Vec::new(); + if row_index == 0 { + if card.indented { + spans.push(Span::raw(" ")); + } else if let Some((_, collapsed)) = parent_group.as_ref() { + spans.push(Span::styled( + if *collapsed { "▸" } else { "▾" }, + Style::default().fg(p.accent), + )); + spans.push(Span::raw(" ")); } else { - p.overlay0 - }; - let branch_indent = if card.indented { " " } else { " " }; - let mut spans = vec![ - Span::styled(branch_indent, Style::default()), - Span::styled(branch_display, Style::default().fg(branch_color)), - ]; - if let Some(parts) = upstream_label { - spans.push(Span::styled(" ", Style::default())); - for (idx, (label, color)) in parts.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::styled(" ", Style::default())); - } - spans.push(Span::styled(label, Style::default().fg(color))); - } + spans.push(Span::raw(" ")); } - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(card.rect.x, row_y + 1, card.rect.width, 1), - ); + } else { + spans.push(Span::raw(if card.indented { " " } else { " " })); } + let prefix_width = if row_index == 0 { + if card.indented { + 3 + } else if parent_group.is_some() { + 2 + } else { + 1 + } + } else if card.indented { + 5 + } else { + 3 + }; + spans.extend(resolved_token_spans( + resolved, + state_icon, + state_text_style, + name_style, + branch_style, + branch_style, + p, + card.rect.width.saturating_sub(prefix_width) as usize, + )); + frame.render_widget( + Paragraph::new(Line::from(spans)), + Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1), + ); } } @@ -1050,27 +1292,19 @@ fn render_agent_detail( let mut row_y = body.y; let body_bottom = body.y + body.height; for detail in details.iter().skip(app.agent_panel_scroll) { - if row_y.saturating_add(1) >= body_bottom { + let label_color = state_label_color(detail.state, detail.seen, p); + let rows = resolved_agent_rows(app, detail); + let height = (rows.len().max(1) as u16).min(body.height); + if row_y.saturating_add(height) > body_bottom { break; } - // Check if this agent entry corresponds to the active session let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - - let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p); - let label_color = state_label_color(detail.state, detail.seen, p); - let label = detail - .state_labels - .get(agent_panel_status_key(detail.state, detail.seen)) - .map(String::as_str) - .unwrap_or_else(|| state_label(detail.state, detail.seen)); - let row_style = if is_active { Style::default().bg(p.surface_dim) } else { Style::default() }; - let name_style = if is_active { Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else { @@ -1082,39 +1316,27 @@ fn render_agent_detail( Style::default().fg(label_color).add_modifier(Modifier::DIM) }; let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - - let primary_label = - format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize); - let name_line = Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(icon, icon_style), - Span::styled(" ", Style::default()), - Span::styled(primary_label, name_style), - ]); - frame.render_widget( - Paragraph::new(name_line).style(row_style), - Rect::new(body.x, row_y, body.width, 1), - ); - row_y += 1; - - let mut status_spans = vec![ - Span::styled(" ", Style::default()), - Span::styled(label, status_style), - ]; - if let Some(agent_label) = &detail.agent_label { - status_spans.push(Span::styled(" · ", agent_style)); - status_spans.push(Span::styled(agent_label, agent_style)); - } - if let Some(custom_status) = &detail.custom_status { - status_spans.push(Span::styled(" · ", agent_style)); - status_spans.push(Span::styled(custom_status.clone(), agent_style)); + let state_icon = agent_icon(detail.state, detail.seen, app.spinner_tick, p); + + for (row_index, resolved) in rows.iter().take(height as usize).enumerate() { + let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })]; + spans.extend(resolved_token_spans( + resolved, + state_icon, + status_style, + name_style, + agent_style, + agent_style, + p, + body.width + .saturating_sub(if row_index == 0 { 1 } else { 3 }) as usize, + )); + frame.render_widget( + Paragraph::new(Line::from(spans)).style(row_style), + Rect::new(body.x, row_y + row_index as u16, body.width, 1), + ); } - frame.render_widget( - Paragraph::new(Line::from(status_spans)).style(row_style), - Rect::new(body.x, row_y, body.width, 1), - ); - row_y += 1; - + row_y = row_y.saturating_add(height); if row_y < body_bottom { row_y += 1; } @@ -1177,6 +1399,208 @@ mod tests { use crate::{detect::Agent, workspace::Workspace}; use ratatui::{backend::TestBackend, Terminal}; + fn row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String { + (0..width) + .map(|x| buffer[(x, row)].symbol()) + .collect::<String>() + .trim_end() + .to_string() + } + + #[test] + fn default_agent_rows_remove_redundant_state_text() { + let mut app = crate::app::state::AppState::test_new(); + let workspace = Workspace::test_new("one"); + let pane_id = workspace.tabs[0].root_pane; + app.workspaces = vec![workspace]; + app.ensure_test_terminals(); + let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); + terminal_state.detected_agent = Some(Agent::Pi); + terminal_state.state = AgentState::Working; + + let area = Rect::new(0, 0, 26, 20); + let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); + terminal + .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); + let body = agent_panel_body_rect(agent_area, false); + + let first = row_text(buffer, body.y, 25); + let second = row_text(buffer, body.y + 1, 25); + assert!(first.contains("one")); + assert_eq!(second, " pi"); + assert!(!first.contains("working")); + assert!(!second.contains("working")); + } + + #[test] + fn narrow_agent_rows_preserve_later_tab_tokens() { + let mut app = crate::app::state::AppState::test_new(); + let mut workspace = Workspace::test_new("very-long-workspace-name"); + let tab_idx = workspace.test_add_tab(Some("logs")); + let pane_id = workspace.tabs[tab_idx].root_pane; + app.workspaces = vec![workspace]; + app.ensure_test_terminals(); + let terminal_id = app.workspaces[0].tabs[tab_idx].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); + + let area = Rect::new(0, 0, 18, 20); + let mut terminal = Terminal::new(TestBackend::new(18, 20)).unwrap(); + terminal + .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); + let body = agent_panel_body_rect(agent_area, false); + let first = row_text(buffer, body.y, 17); + + assert!(first.contains("logs"), "rendered row: {first:?}"); + assert!(first.contains('·'), "rendered row: {first:?}"); + } + + #[test] + fn stripped_terminal_title_renders_with_unicode_width_truncation() { + let mut app = crate::app::state::AppState::test_new(); + let workspace = Workspace::test_new("one"); + let pane_id = workspace.tabs[0].root_pane; + app.workspaces = vec![workspace]; + app.ensure_test_terminals(); + let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(Agent::Claude); + terminal.set_terminal_title(Some("⠋ 修复🙂标题很长".into())); + app.sidebar_agents.rows = vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]]; + + let area = Rect::new(0, 0, 10, 12); + let mut renderer = Terminal::new(TestBackend::new(10, 12)).unwrap(); + renderer + .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); + let body = agent_panel_body_rect(agent_area, false); + let rendered = row_text(renderer.backend().buffer(), body.y, 9); + + assert!(!rendered.contains('⠋')); + assert!(rendered.contains('修') && rendered.contains('复')); + + let spans = resolved_token_spans( + &[ResolvedToken::TerminalTitle("修复🙂标题很长".into())], + ("", Style::default()), + Style::default(), + Style::default(), + Style::default(), + Style::default(), + &app.palette, + 8, + ); + let text = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(); + assert!(display_width(&text) <= 8, "resolved title: {text:?}"); + } + + #[test] + fn variable_agent_heights_pack_the_bottom_and_reveal_targets() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![ + Workspace::test_new("one"), + Workspace::test_new("two"), + Workspace::test_new("three"), + ]; + app.ensure_test_terminals(); + for workspace in &app.workspaces { + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); + } + let first_pane = app.workspaces[0].tabs[0].root_pane; + let first_terminal = app.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.terminals + .get_mut(&first_terminal) + .unwrap() + .metadata_tokens + .patch( + std::collections::HashMap::from([ + ("a".into(), Some("a".into())), + ("b".into(), Some("b".into())), + ]), + None, + std::time::Instant::now(), + ); + app.sidebar_agents.rows = vec![ + vec![crate::config::AgentSidebarToken::Agent], + vec![crate::config::AgentSidebarToken::Custom("a".into())], + vec![crate::config::AgentSidebarToken::Custom("b".into())], + ]; + let area = Rect::new(0, 0, 20, 6); + + let metrics = agent_panel_scroll_metrics(&app, area); + assert_eq!(metrics.max_offset_from_bottom, 1); + assert_eq!(agent_panel_scroll_for_target(&app, area, 0, 2), 1); + } + + #[test] + fn oversized_space_layout_is_clipped_to_the_section_body() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]; 6]; + let area = Rect::new(0, 0, 20, 10); + let workspace_area = workspace_list_rect(area, app.sidebar_section_split); + let body = workspace_list_body_rect(workspace_area, false); + + let metrics = workspace_list_scroll_metrics(&app, workspace_area); + let (cards, _) = compute_workspace_list_areas(&app, area); + + assert_eq!(metrics.viewport_rows, 1); + assert_eq!(cards.len(), 1); + assert_eq!(cards[0].ws_idx, 0); + assert_eq!(cards[0].rect.height, body.height); + } + + #[test] + fn oversized_agent_override_is_clipped_to_the_panel_body() { + let mut app = crate::app::state::AppState::test_new(); + let workspace = Workspace::test_new("one"); + let pane_id = workspace.tabs[0].root_pane; + app.workspaces = vec![workspace]; + app.ensure_test_terminals(); + let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); + app.sidebar_agents.rows_by_agent.insert( + "claude".into(), + vec![vec![crate::config::AgentSidebarToken::Agent]; 6], + ); + let panel = Rect::new(0, 0, 20, 5); + + let metrics = agent_panel_scroll_metrics(&app, panel); + + assert_eq!(metrics.viewport_rows, 1); + assert_eq!(metrics.max_offset_from_bottom, 0); + let entry = agent_panel_entries(&app).pop().unwrap(); + assert_eq!( + agent_entry_height_in_body(&app, &entry, agent_panel_body_rect(panel, false).height), + agent_panel_body_rect(panel, false).height + ); + } + #[test] fn render_sidebar_toggle_draws_expanded_collapse_icon() { let app = crate::app::state::AppState::test_new(); @@ -1488,27 +1912,6 @@ mod tests { } #[test] - fn all_workspaces_primary_label_truncates_workspace_and_tab() { - let entry = AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: crate::layout::PaneId::from_raw(1), - primary_label: "agent-browser".into(), - primary_tab_label: Some("test-escalation".into()), - agent_label: Some("claude".into()), - state: AgentState::Idle, - seen: true, - last_agent_state_change_seq: None, - custom_status: None, - state_labels: std::collections::HashMap::new(), - }; - - let label = format_agent_panel_primary_label(&entry, 18); - - assert_eq!(label, "agent-bro… · test…"); - } - - #[test] fn expanded_sidebar_sections_handle_tiny_heights() { let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9); @@ -1638,7 +2041,7 @@ mod tests { } #[test] - fn compact_space_group_scroll_offset_can_start_inside_group() { + fn compact_space_group_scroll_clamps_when_all_entries_fit() { let mut app = AppState::test_new(); app.workspaces = vec![ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), @@ -1651,8 +2054,9 @@ mod tests { let (cards, headers) = compute_workspace_list_areas(&app, area); assert!(headers.is_empty()); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 2); + assert_eq!(app.workspace_scroll, 0); + assert_eq!(cards.len(), 3); + assert_eq!(cards[2].ws_idx, 2); } #[test] diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs new file mode 100644 index 00000000..43c24d05 --- /dev/null +++ b/src/ui/sidebar/tokens.rs @@ -0,0 +1,285 @@ +use crate::config::{ + AgentSidebarToken, AgentsSidebarConfig, SpaceSidebarToken, SpacesSidebarConfig, +}; + +use super::AgentPanelEntry; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ResolvedToken { + StateIcon, + StateText(String), + Workspace(String), + Tab(String), + Pane(String), + Agent(String), + TerminalTitle(String), + Branch(String), + GitStatus { ahead: usize, behind: usize }, + Custom(String), +} + +pub(super) fn agent_rows( + config: &AgentsSidebarConfig, + entry: &AgentPanelEntry, + state_text: &str, +) -> Vec<Vec<ResolvedToken>> { + config + .rows_for_agent(entry.agent) + .iter() + .filter_map(|row| { + let resolved = row + .iter() + .filter_map(|token| match token { + AgentSidebarToken::StateIcon => Some(ResolvedToken::StateIcon), + AgentSidebarToken::StateText => { + Some(ResolvedToken::StateText(state_text.to_string())) + } + AgentSidebarToken::Workspace => { + Some(ResolvedToken::Workspace(entry.primary_label.clone())) + } + AgentSidebarToken::Tab => { + entry.primary_tab_label.clone().map(ResolvedToken::Tab) + } + AgentSidebarToken::Pane => entry.pane_label.clone().map(ResolvedToken::Pane), + AgentSidebarToken::Agent => entry.agent_label.clone().map(ResolvedToken::Agent), + AgentSidebarToken::TerminalTitle => entry + .terminal_title + .clone() + .map(ResolvedToken::TerminalTitle), + AgentSidebarToken::TerminalTitleStripped => entry + .terminal_title_stripped + .clone() + .map(ResolvedToken::TerminalTitle), + AgentSidebarToken::Custom(name) => { + entry.tokens.get(name).cloned().map(ResolvedToken::Custom) + } + }) + .collect::<Vec<_>>(); + (!resolved.is_empty()).then_some(resolved) + }) + .collect() +} + +pub(super) struct SpaceTokenContext<'a> { + pub workspace: &'a str, + pub branch: Option<&'a str>, + pub state_text: &'a str, + pub ahead_behind: Option<(usize, usize)>, + pub tokens: &'a std::collections::HashMap<String, String>, + pub suppress_git_details: bool, +} + +pub(super) fn space_rows( + config: &SpacesSidebarConfig, + context: SpaceTokenContext<'_>, +) -> Vec<Vec<ResolvedToken>> { + config + .rows + .iter() + .filter_map(|row| { + let resolved = row + .iter() + .filter_map(|token| match token { + SpaceSidebarToken::StateIcon => Some(ResolvedToken::StateIcon), + SpaceSidebarToken::StateText => { + Some(ResolvedToken::StateText(context.state_text.to_string())) + } + SpaceSidebarToken::Workspace => { + Some(ResolvedToken::Workspace(context.workspace.to_string())) + } + SpaceSidebarToken::Branch if !context.suppress_git_details => context + .branch + .map(|branch| ResolvedToken::Branch(branch.to_string())), + SpaceSidebarToken::Branch => None, + SpaceSidebarToken::GitStatus if !context.suppress_git_details => context + .ahead_behind + .filter(|(ahead, behind)| *ahead > 0 || *behind > 0) + .map(|(ahead, behind)| ResolvedToken::GitStatus { ahead, behind }), + SpaceSidebarToken::GitStatus => None, + SpaceSidebarToken::Custom(name) => { + context.tokens.get(name).cloned().map(ResolvedToken::Custom) + } + }) + .collect::<Vec<_>>(); + (!resolved.is_empty()).then_some(resolved) + }) + .collect() +} + +pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { + if matches!(previous, ResolvedToken::StateIcon) + || matches!(current, ResolvedToken::GitStatus { .. }) + { + " " + } else { + " · " + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::AgentState; + + fn entry() -> AgentPanelEntry { + AgentPanelEntry { + ws_idx: 0, + tab_idx: 0, + pane_id: crate::layout::PaneId::from_raw(1), + primary_label: "repo".into(), + primary_tab_label: None, + pane_label: None, + terminal_title: None, + terminal_title_stripped: None, + agent_label: Some("pi".into()), + agent: Some(crate::detect::Agent::Pi), + state: AgentState::Working, + seen: true, + last_agent_state_change_seq: None, + state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), + } + } + + #[test] + fn missing_custom_tokens_elide_rows_and_separators() { + let entry = entry(); + let config = AgentsSidebarConfig { + rows: vec![ + vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Custom("missing".into()), + ], + vec![AgentSidebarToken::Custom("missing".into())], + vec![AgentSidebarToken::Agent], + ], + rows_by_agent: Default::default(), + }; + + let rows = agent_rows(&config, &entry, "working"); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0], vec![ResolvedToken::StateIcon]); + assert_eq!(rows[1], vec![ResolvedToken::Agent("pi".into())]); + } + + #[test] + fn state_text_and_arbitrary_values_are_independent_tokens() { + let mut entry = entry(); + entry + .tokens + .insert("summary".into(), "reviewing auth".into()); + let config = AgentsSidebarConfig { + rows: vec![vec![ + AgentSidebarToken::StateText, + AgentSidebarToken::Custom("summary".into()), + ]], + rows_by_agent: Default::default(), + }; + + assert_eq!( + agent_rows(&config, &entry, "deep in the mines"), + vec![vec![ + ResolvedToken::StateText("deep in the mines".into()), + ResolvedToken::Custom("reviewing auth".into()), + ]] + ); + } + + #[test] + fn terminal_title_builtins_are_distinct_from_custom_tokens() { + let mut entry = entry(); + entry.terminal_title = Some("⠋ raw title".into()); + entry.terminal_title_stripped = Some("raw title".into()); + entry + .tokens + .insert("terminal_title".into(), "custom title".into()); + let config = AgentsSidebarConfig { + rows: vec![vec![ + AgentSidebarToken::TerminalTitle, + AgentSidebarToken::TerminalTitleStripped, + AgentSidebarToken::Custom("terminal_title".into()), + ]], + rows_by_agent: Default::default(), + }; + + assert_eq!( + agent_rows(&config, &entry, "working"), + vec![vec![ + ResolvedToken::TerminalTitle("⠋ raw title".into()), + ResolvedToken::TerminalTitle("raw title".into()), + ResolvedToken::Custom("custom title".into()), + ]] + ); + } + + #[test] + fn known_agent_override_replaces_default_rows() { + let mut config = AgentsSidebarConfig { + rows: vec![vec![AgentSidebarToken::Workspace]], + rows_by_agent: Default::default(), + }; + config + .rows_by_agent + .insert("pi".into(), vec![vec![AgentSidebarToken::Agent]]); + let mut pi = entry(); + pi.agent_label = Some("renamed pi".into()); + + assert_eq!( + agent_rows(&config, &pi, "working"), + vec![vec![ResolvedToken::Agent("renamed pi".into())]] + ); + + pi.agent = None; + assert_eq!( + agent_rows(&config, &pi, "working"), + vec![vec![ResolvedToken::Workspace("repo".into())]] + ); + } + + #[test] + fn grouped_children_suppress_all_builtin_git_details() { + let config = SpacesSidebarConfig::default(); + + assert_eq!( + space_rows( + &config, + SpaceTokenContext { + workspace: "feature", + branch: Some("worktree/feature"), + state_text: "idle", + ahead_behind: Some((2, 1)), + tokens: &std::collections::HashMap::new(), + suppress_git_details: true, + }, + ), + vec![vec![ + ResolvedToken::StateIcon, + ResolvedToken::Workspace("feature".into()), + ]] + ); + } + + #[test] + fn workspace_custom_token_can_replace_git_specific_details() { + let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]); + let config = SpacesSidebarConfig { + rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]], + }; + + assert_eq!( + space_rows( + &config, + SpaceTokenContext { + workspace: "repo", + branch: None, + state_text: "idle", + ahead_behind: None, + tokens: &tokens, + suppress_git_details: false, + }, + ), + vec![vec![ResolvedToken::Custom("2 changes".into())]] + ); + } +} diff --git a/src/workspace.rs b/src/workspace.rs index f6060338..e90bd359 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -157,6 +157,8 @@ pub struct Workspace { pub(crate) cached_git_space: Option<GitSpaceMetadata>, /// Explicit Herdr-managed worktree grouping provenance. pub worktree_space: Option<WorktreeSpaceMembership>, + pub(crate) metadata_tokens: crate::metadata_tokens::MetadataTokens, + pub(crate) metadata_token_sequences: HashMap<String, u64>, /// Public pane numbers within this workspace. Closed pane numbers are not reused. pub public_pane_numbers: HashMap<PaneId, usize>, pub(crate) next_public_pane_number: usize, @@ -216,6 +218,8 @@ impl Workspace { cached_git_ahead_behind: None, cached_git_space: git_space_metadata(&identity_cwd), worktree_space: None, + metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), + metadata_token_sequences: HashMap::new(), public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, @@ -397,6 +401,8 @@ impl Workspace { cached_git_ahead_behind: None, cached_git_space: None, worktree_space: None, + metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), + metadata_token_sequences: HashMap::new(), public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, @@ -1208,6 +1214,8 @@ impl Workspace { cached_git_ahead_behind: None, cached_git_space: None, worktree_space: None, + metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), + metadata_token_sequences: HashMap::new(), public_pane_numbers, next_public_pane_number: 2, next_public_tab_number: 2, diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 365decbf..a3eed860 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -12,14 +12,16 @@ pub struct PaneDetail { pub tab_idx: usize, pub tab_label: String, pub label: String, + pub pane_label: Option<String>, + pub terminal_title: Option<String>, + pub terminal_title_stripped: Option<String>, pub agent_label: String, - #[allow(dead_code)] pub agent: Option<Agent>, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option<u64>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, + pub tokens: HashMap<String, String>, } impl Tab { @@ -57,13 +59,18 @@ impl Tab { tab_idx, tab_label: tab_label.to_string(), label: agent_label.clone(), + pane_label: terminal + .effective_title() + .or_else(|| terminal.manual_label.clone()), + terminal_title: terminal.terminal_title.clone(), + terminal_title_stripped: terminal.terminal_title_stripped(), agent_label, agent: terminal.effective_known_agent(), state: terminal.state, seen: pane.seen, last_agent_state_change_seq: terminal.last_agent_state_change_seq, - custom_status: presentation.custom_status, state_labels: presentation.state_labels, + tokens: terminal.metadata_tokens.values(), }) }) .collect() |
