aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:26:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 03:30:13 +0300
commit5cfe5e5e730246948b0c9428ad876b6938590aad (patch)
tree53d4bbe1aff6887f63f279eed1b5e4e11e7331b7 /src
parent7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff)
feat: add configurable sidebar metadata tokens
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/api/schema/agents.rs9
-rw-r--r--src/api/schema/common.rs22
-rw-r--r--src/api/schema/events.rs22
-rw-r--r--src/api/schema/panes.rs19
-rw-r--r--src/api/schema/tests.rs10
-rw-r--r--src/api/schema/workspaces.rs16
-rw-r--r--src/api/server.rs5
-rw-r--r--src/api/subscriptions.rs84
-rw-r--r--src/api/wait.rs1
-rw-r--r--src/app/actions.rs89
-rw-r--r--src/app/agents.rs4
-rw-r--r--src/app/api.rs25
-rw-r--r--src/app/api/panes.rs271
-rw-r--r--src/app/api/plugins/context.rs3
-rw-r--r--src/app/api/plugins/mod.rs1
-rw-r--r--src/app/api/workspaces.rs157
-rw-r--r--src/app/api_helpers.rs119
-rw-r--r--src/app/creation.rs5
-rw-r--r--src/app/input/sidebar.rs66
-rw-r--r--src/app/mod.rs65
-rw-r--r--src/app/runtime.rs43
-rw-r--r--src/app/state.rs4
-rw-r--r--src/app/terminal_titles.rs148
-rw-r--r--src/cli.rs12
-rw-r--r--src/cli/pane.rs63
-rw-r--r--src/cli/spec.rs19
-rw-r--r--src/cli/workspace.rs91
-rw-r--r--src/config.rs5
-rw-r--r--src/config/model.rs7
-rw-r--r--src/config/sidebar.rs400
-rw-r--r--src/events.rs3
-rw-r--r--src/handoff_runtime.rs2
-rw-r--r--src/main.rs15
-rw-r--r--src/metadata_tokens.rs259
-rw-r--r--src/pane.rs13
-rw-r--r--src/pane/osc.rs46
-rw-r--r--src/pane/terminal.rs18
-rw-r--r--src/persist/restore.rs2
-rw-r--r--src/persist/snapshot.rs1
-rw-r--r--src/server/headless.rs122
-rw-r--r--src/terminal/metadata.rs439
-rw-r--r--src/terminal/mod.rs2
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/terminal/state.rs229
-rw-r--r--src/terminal/title.rs64
-rw-r--r--src/ui.rs15
-rw-r--r--src/ui/mobile.rs10
-rw-r--r--src/ui/navigator.rs3
-rw-r--r--src/ui/sidebar.rs888
-rw-r--r--src/ui/sidebar/tokens.rs285
-rw-r--r--src/workspace.rs8
-rw-r--r--src/workspace/aggregate.rs13
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(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.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, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.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(&params.workspace_id) else {
+ return workspace_not_found(id, &params.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, &params.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()
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 494ed14e..1ce262b4 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -30,6 +30,16 @@ const TERMINAL_SESSION_OBSERVE_USAGE: &str =
const TERMINAL_SESSION_CONTROL_USAGE: &str =
"usage: herdr terminal session control <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);
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index dec8d7a9..76e1caa2 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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()