aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 17:10:28 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 17:10:28 +0300
commit3750e0bae21c54da3947d337584f180a21d0bc67 (patch)
tree4542d8af76b4fee0c428ca5e9378a06946ef62ca /src
parent1b980eaa9d4cbc050b6b81525e4b6b72ae853be5 (diff)
feat: add pane metadata reporting
refs #36
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs93
-rw-r--r--src/api/server.rs1
-rw-r--r--src/api/subscriptions.rs32
-rw-r--r--src/app/actions.rs125
-rw-r--r--src/app/agents.rs4
-rw-r--r--src/app/api.rs17
-rw-r--r--src/app/api/panes.rs134
-rw-r--r--src/app/creation.rs6
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/app/runtime.rs16
-rw-r--r--src/cli/pane.rs195
-rw-r--r--src/events.rs17
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh5
-rw-r--r--src/integration/assets/codex/herdr-agent-state.sh5
-rw-r--r--src/integration/assets/omp/herdr-agent-state.ts5
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js5
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts5
-rw-r--r--src/integration/mod.rs16
-rw-r--r--src/logging.rs1
-rw-r--r--src/server/headless.rs102
-rw-r--r--src/terminal/metadata.rs1282
-rw-r--r--src/terminal/mod.rs5
-rw-r--r--src/terminal/state.rs86
-rw-r--r--src/ui/mobile.rs11
-rw-r--r--src/ui/navigator.rs26
-rw-r--r--src/ui/panes.rs2
-rw-r--r--src/ui/sidebar.rs20
-rw-r--r--src/workspace/aggregate.rs10
29 files changed, 2138 insertions, 91 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 1f2ab2bb..67964e83 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -39,6 +39,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::PaneSplit(_)
| Method::PaneRename(_)
| Method::PaneReportAgent(_)
+ | Method::PaneReportMetadata(_)
| Method::PaneClearAgentAuthority(_)
| Method::PaneReleaseAgent(_)
| Method::PaneClose(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 4929d4a7..675d0cbc 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -82,6 +84,8 @@ pub enum Method {
PaneRead(PaneReadParams),
#[serde(rename = "pane.report_agent")]
PaneReportAgent(PaneReportAgentParams),
+ #[serde(rename = "pane.report_metadata")]
+ PaneReportMetadata(PaneReportMetadataParams),
#[serde(rename = "pane.clear_agent_authority")]
PaneClearAgentAuthority(PaneClearAgentAuthorityParams),
#[serde(rename = "pane.release_agent")]
@@ -350,6 +354,36 @@ pub struct PaneReportAgentParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReportMetadataParams {
+ pub pane_id: String,
+ pub source: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub applies_to_source: 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>,
+ #[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)]
+ 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")]
+ pub ttl_ms: Option<u64>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneClearAgentAuthorityParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -759,7 +793,15 @@ pub struct AgentInfo {
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: 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>,
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>,
pub workspace_id: String,
pub tab_id: String,
pub pane_id: String,
@@ -782,9 +824,15 @@ pub struct PaneInfo {
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: 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>,
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>,
pub revision: u64,
}
@@ -853,6 +901,12 @@ pub struct PaneAgentStatusChangedEvent {
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>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub state_labels: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -921,7 +975,13 @@ pub enum EventData {
workspace_id: String,
agent_status: AgentStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ 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>,
},
}
@@ -993,6 +1053,33 @@ mod tests {
}
#[test]
+ fn request_round_trips_for_pane_report_metadata() {
+ let request = Request {
+ id: "req_metadata".into(),
+ method: Method::PaneReportMetadata(PaneReportMetadataParams {
+ pane_id: "1-1".into(),
+ source: "user:claude-title".into(),
+ agent: Some("claude".into()),
+ applies_to_source: Some("herdr:claude".into()),
+ title: Some("Refactor auth".into()),
+ display_agent: Some("Claude auth".into()),
+ custom_status: Some("refactor auth".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,
+ seq: Some(42),
+ ttl_ms: Some(3_600_000),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
fn request_round_trips_for_pane_clear_agent_authority() {
let request = Request {
id: "req_clear".into(),
@@ -1307,8 +1394,11 @@ mod tests {
cwd: Some("/worktrees/herdr/worktree-api".into()),
label: None,
agent: None,
+ title: None,
+ display_agent: None,
agent_status: AgentStatus::Unknown,
custom_status: None,
+ state_labels: HashMap::new(),
revision: 0,
},
worktree: WorktreeInfo {
@@ -1353,8 +1443,11 @@ mod tests {
cwd: Some("/tmp/review".into()),
label: None,
agent: None,
+ title: None,
+ display_agent: None,
agent_status: AgentStatus::Unknown,
custom_status: None,
+ state_labels: HashMap::new(),
revision: 0,
},
},
diff --git a/src/api/server.rs b/src/api/server.rs
index 696e1b65..cd130e47 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -303,6 +303,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::PaneSendInput(_) => "pane.send_input",
Method::PaneRead(_) => "pane.read",
Method::PaneReportAgent(_) => "pane.report_agent",
+ Method::PaneReportMetadata(_) => "pane.report_metadata",
Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority",
Method::PaneReleaseAgent(_) => "pane.release_agent",
Method::PaneClose(_) => "pane.close",
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index a6d8ff97..aaa7e5ac 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -50,9 +50,29 @@ pub(super) struct ActiveAgentStatusChangedSubscription {
status_filter: Option<crate::api::schema::AgentStatus>,
last_status: Option<crate::api::schema::AgentStatus>,
emit_initial_match: bool,
+ last_presentation: Option<PanePresentationSnapshot>,
request_prefix: String,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct PanePresentationSnapshot {
+ title: Option<String>,
+ display_agent: Option<String>,
+ custom_status: Option<String>,
+ state_labels: std::collections::HashMap<String, String>,
+}
+
+impl PanePresentationSnapshot {
+ fn from(pane: &crate::api::schema::PaneInfo) -> Self {
+ Self {
+ title: pane.title.clone(),
+ display_agent: pane.display_agent.clone(),
+ custom_status: pane.custom_status.clone(),
+ state_labels: pane.state_labels.clone(),
+ }
+ }
+}
+
pub(super) struct ActiveEventSubscription {
event_kind: crate::api::schema::EventKind,
last_sequence: u64,
@@ -187,6 +207,7 @@ impl ActiveSubscription {
status_filter: agent_status,
last_status: Some(probe.agent_status),
emit_initial_match,
+ last_presentation: Some(PanePresentationSnapshot::from(&probe)),
request_prefix: format!("{request_id}:sub:{index}"),
},
))
@@ -268,13 +289,19 @@ impl ActiveAgentStatusChangedSubscription {
)
.ok()?;
let current_status = pane.agent_status;
+ let current_presentation = PanePresentationSnapshot::from(&pane);
let previous_status = self.last_status.replace(current_status);
+ let previous_presentation = self.last_presentation.replace(current_presentation.clone());
+ let presentation_changed = previous_presentation
+ .as_ref()
+ .is_some_and(|previous| previous != &current_presentation);
+ let status_changed = previous_status.is_some_and(|previous| previous != current_status);
let should_emit = if self.emit_initial_match {
self.emit_initial_match = false;
self.status_filter
.is_some_and(|wanted| wanted == current_status)
} else {
- previous_status.is_some_and(|previous| previous != current_status)
+ (status_changed || presentation_changed)
&& !self
.status_filter
.is_some_and(|wanted| wanted != current_status)
@@ -290,7 +317,10 @@ impl ActiveAgentStatusChangedSubscription {
workspace_id: pane.workspace_id,
agent_status: current_status,
agent: pane.agent,
+ title: pane.title,
+ display_agent: pane.display_agent,
custom_status: pane.custom_status,
+ state_labels: pane.state_labels,
}),
})
}
diff --git a/src/app/actions.rs b/src/app/actions.rs
index da9e538a..ab2539bf 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -93,10 +93,11 @@ pub struct PaneStateUpdate {
pub previous_agent_label: Option<String>,
pub previous_known_agent: Option<Agent>,
pub previous_state: AgentState,
+ pub previous_presentation: crate::terminal::EffectivePresentation,
pub agent_label: Option<String>,
pub known_agent: Option<Agent>,
pub state: AgentState,
- pub custom_status: Option<String>,
+ pub presentation: crate::terminal::EffectivePresentation,
}
// ---------------------------------------------------------------------------
@@ -333,23 +334,37 @@ impl AppState {
let terminal = self.terminals.get(&pane.attached_terminal_id);
let pane_number = ws.public_pane_number(pane_id).unwrap_or(0);
let label = terminal
- .and_then(|terminal| terminal.manual_label.as_deref())
- .or_else(|| terminal.and_then(|terminal| terminal.agent_name.as_deref()))
- .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()))
- .map(str::to_string)
+ .and_then(|terminal| terminal.effective_title())
+ .or_else(|| {
+ terminal
+ .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string))
+ })
+ .or_else(|| {
+ terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string))
+ })
+ .or_else(|| {
+ terminal
+ .and_then(|terminal| terminal.effective_agent_label().map(str::to_string))
+ })
.or_else(|| {
launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref()))
})
.unwrap_or_else(|| format!("pane {pane_number}"));
- let agent_label = terminal
- .and_then(|terminal| terminal.agent_name.as_deref())
- .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()));
+ let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent());
+ let agent_label = display_agent.as_deref().or_else(|| {
+ terminal
+ .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
- .map(str::to_string)
+ .or(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}"),
@@ -656,6 +671,60 @@ fn activity_summary_for_panes<'a>(
// ---------------------------------------------------------------------------
impl AppState {
+ pub(crate) fn next_agent_metadata_expiry(&self) -> Option<std::time::Instant> {
+ self.terminals
+ .values()
+ .filter_map(|terminal| terminal.next_agent_metadata_expiry())
+ .min()
+ }
+
+ pub(crate) fn expire_agent_metadata_at(
+ &mut self,
+ scheduled_deadline: std::time::Instant,
+ now: std::time::Instant,
+ ) -> Vec<PaneStateUpdate> {
+ let pane_terminals: Vec<_> = self
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs.iter().flat_map(move |tab| {
+ tab.layout
+ .pane_ids()
+ .into_iter()
+ .filter_map(move |pane_id| {
+ ws.pane_state(pane_id)
+ .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone()))
+ })
+ })
+ })
+ .collect();
+ pane_terminals
+ .into_iter()
+ .filter_map(|(ws_idx, pane_id, terminal_id)| {
+ let mutation = self
+ .terminals
+ .get_mut(&terminal_id)?
+ .expire_agent_metadata_at(scheduled_deadline, now)?;
+ let change = mutation.effective_state_change?;
+ let update = PaneStateUpdate {
+ pane_id,
+ ws_idx,
+ previous_agent_label: change.previous_agent_label.clone(),
+ previous_known_agent: change.previous_known_agent,
+ previous_state: change.previous_state,
+ previous_presentation: change.previous_presentation.clone(),
+ agent_label: change.agent_label.clone(),
+ known_agent: change.known_agent,
+ state: change.state,
+ presentation: change.presentation.clone(),
+ };
+ self.apply_pane_state_change(ws_idx, pane_id, &change);
+ Some(update)
+ })
+ .collect()
+ }
+
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;
@@ -1844,6 +1913,41 @@ impl AppState {
})
.into_iter()
.collect(),
+ AppEvent::HookMetadataReported {
+ pane_id,
+ source,
+ agent_label,
+ applies_to_source,
+ title,
+ display_agent,
+ custom_status,
+ state_labels,
+ clear_title,
+ clear_display_agent,
+ clear_custom_status,
+ clear_state_labels,
+ seq,
+ ttl,
+ } => self
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.set_agent_metadata(crate::terminal::AgentMetadataReport {
+ source,
+ agent_label,
+ applies_to_source,
+ title,
+ display_agent,
+ custom_status,
+ state_labels,
+ clear_title,
+ clear_display_agent,
+ clear_custom_status,
+ clear_state_labels,
+ ttl,
+ seq,
+ })
+ })
+ .into_iter()
+ .collect(),
AppEvent::HookAuthorityCleared {
pane_id,
source,
@@ -1904,10 +2008,11 @@ impl AppState {
previous_agent_label: change.previous_agent_label.clone(),
previous_known_agent: change.previous_known_agent,
previous_state: change.previous_state,
+ previous_presentation: change.previous_presentation.clone(),
agent_label: change.agent_label.clone(),
known_agent: change.known_agent,
state: change.state,
- custom_status: change.custom_status.clone(),
+ presentation: change.presentation.clone(),
};
self.apply_pane_state_change(ws_idx, pane_id, &change);
Some(update)
diff --git a/src/app/agents.rs b/src/app/agents.rs
index b1276a06..b9534ce2 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -408,7 +408,11 @@ impl App {
terminal_id: pane.terminal_id,
name: terminal.agent_name.clone(),
agent: pane.agent,
+ title: pane.title,
+ display_agent: pane.display_agent,
agent_status: pane.agent_status,
+ custom_status: pane.custom_status,
+ state_labels: pane.state_labels,
workspace_id: pane.workspace_id,
tab_id: pane.tab_id,
pane_id: pane.pane_id,
diff --git a/src/app/api.rs b/src/app/api.rs
index 2488ce40..6319bdb6 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -88,6 +88,7 @@ impl App {
for update in &pane_updates {
self.emit_pane_state_update(update);
}
+ self.sync_agent_metadata_deadline();
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
@@ -209,7 +210,7 @@ impl App {
}
}
- fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
+ pub(crate) fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else {
return;
};
@@ -226,7 +227,9 @@ impl App {
});
}
- if update.previous_state != update.state {
+ if update.previous_state != update.state
+ || update.previous_presentation != update.presentation
+ {
let agent_status = self
.state
.workspaces
@@ -234,14 +237,17 @@ impl App {
.and_then(|ws| ws.pane_state(update.pane_id))
.map(|pane| pane_agent_status(update.state, pane.seen))
.unwrap_or_else(|| pane_agent_status(update.state, true));
- let custom_status = update.custom_status.clone();
+ let presentation = update.presentation.clone();
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneAgentStatusChanged,
data: crate::api::schema::EventData::PaneAgentStatusChanged {
pane_id,
workspace_id,
agent_status,
- custom_status,
+ title: presentation.title,
+ display_agent: presentation.display_agent,
+ custom_status: presentation.custom_status,
+ state_labels: presentation.state_labels,
},
});
}
@@ -413,6 +419,9 @@ impl App {
Method::PaneReportAgent(params) => {
return self.handle_pane_report_agent(request.id, params);
}
+ Method::PaneReportMetadata(params) => {
+ return self.handle_pane_report_metadata(request.id, params);
+ }
Method::PaneClearAgentAuthority(params) => {
return self.handle_pane_clear_agent_authority(request.id, params);
}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 9a31ec34..a81a6080 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -3,8 +3,8 @@ use bytes::Bytes;
use crate::api::schema::{
EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams,
PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams,
- PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams,
- PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult,
+ PaneReportAgentParams, PaneReportMetadataParams, PaneSendInputParams, PaneSendKeysParams,
+ PaneSendTextParams, PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult,
};
use crate::app::{App, Mode};
@@ -198,6 +198,102 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
+ pub(super) fn handle_pane_report_metadata(
+ &mut self,
+ id: String,
+ params: PaneReportMetadataParams,
+ ) -> String {
+ 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() {
+ Some(agent) => match normalize_reported_agent_label(agent) {
+ Some(agent_label) => Some(agent_label),
+ None => return invalid_agent(id),
+ },
+ None => None,
+ };
+ let Some(source) = normalize_optional_text(Some(params.source)) else {
+ return encode_error(id, "invalid_metadata_request", "missing metadata source");
+ };
+ 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 ttl = params.ttl_ms.map(std::time::Duration::from_millis);
+ 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) => {
+ let Some(applies_to_source) = normalize_optional_text(Some(applies_to_source))
+ else {
+ return encode_error(
+ id,
+ "invalid_metadata_request",
+ "missing metadata authority source",
+ );
+ };
+ Some(applies_to_source)
+ }
+ None => None,
+ };
+ let state_labels = match normalize_state_labels(params.state_labels) {
+ Ok(labels) => labels,
+ Err(status) => {
+ return encode_error(
+ id,
+ "invalid_state_label",
+ format!("unknown state label: {status}"),
+ );
+ }
+ };
+ 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(
+ id,
+ "invalid_metadata_request",
+ "cannot set and clear the same metadata field",
+ );
+ }
+ if title.is_none()
+ && display_agent.is_none()
+ && custom_status.is_none()
+ && state_labels.is_empty()
+ && !params.clear_title
+ && !params.clear_display_agent
+ && !params.clear_custom_status
+ && !params.clear_state_labels
+ {
+ return encode_error(
+ id,
+ "invalid_metadata_request",
+ "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,
+ });
+
+ encode_success(id, ResponseResult::Ok {})
+ }
+
pub(super) fn handle_pane_clear_agent_authority(
&mut self,
id: String,
@@ -353,6 +449,40 @@ impl App {
}
}
+fn normalize_optional_text(value: Option<String>) -> Option<String> {
+ let value = value?.trim().to_string();
+ (!value.is_empty()).then_some(value)
+}
+
+fn normalize_presentation_text(value: Option<String>) -> Option<String> {
+ let trimmed = value?.trim().to_string();
+ let normalized: String = trimmed
+ .chars()
+ .filter(|ch| !ch.is_control())
+ .take(80)
+ .collect();
+ (!normalized.trim().is_empty()).then(|| normalized.trim().to_string())
+}
+
+fn normalize_state_labels(
+ labels: std::collections::HashMap<String, String>,
+) -> Result<std::collections::HashMap<String, String>, String> {
+ labels
+ .into_iter()
+ .map(|(status, label)| {
+ let status = status.trim().to_ascii_lowercase();
+ if !matches!(
+ status.as_str(),
+ "idle" | "working" | "blocked" | "done" | "unknown"
+ ) {
+ return Err(status);
+ }
+ Ok(normalize_presentation_text(Some(label)).map(|label| (status, label)))
+ })
+ .filter_map(Result::transpose)
+ .collect()
+}
+
fn pane_not_found(id: String, pane_id: &str) -> String {
encode_error(id, "pane_not_found", format!("pane {pane_id} not found"))
}
diff --git a/src/app/creation.rs b/src/app/creation.rs
index 5bb14953..a034fdc4 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -274,6 +274,7 @@ impl App {
&& ws
.focused_pane_id()
.is_some_and(|focused| focused == pane_id);
+ let presentation = terminal.effective_presentation();
Some(crate::api::schema::PaneInfo {
pane_id: self.public_pane_id(ws_idx, pane_id)?,
terminal_id: terminal.id.to_string(),
@@ -285,8 +286,11 @@ impl App {
.map(|cwd| cwd.display().to_string()),
label: terminal.manual_label.clone(),
agent: terminal.effective_agent_label().map(str::to_string),
+ title: presentation.title,
+ display_agent: presentation.display_agent,
agent_status: pane_agent_status(terminal.state, pane.seen),
- custom_status: terminal.effective_custom_status().map(str::to_string),
+ custom_status: presentation.custom_status,
+ state_labels: presentation.state_labels,
revision: terminal.revision,
})
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1c221be8..cad09a71 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -102,6 +102,7 @@ pub struct App {
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
+ pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
@@ -563,6 +564,7 @@ impl App {
next_animation_tick: None,
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
+ agent_metadata_deadline: None,
session_save_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 2e1f4d81..64232397 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -206,6 +206,17 @@ impl App {
self.save_session_now();
}
+ if let Some(deadline) = self
+ .agent_metadata_deadline
+ .filter(|deadline| now >= *deadline)
+ {
+ for update in self.state.expire_agent_metadata_at(deadline, now) {
+ self.emit_pane_state_update(&update);
+ }
+ self.sync_agent_metadata_deadline();
+ changed = true;
+ }
+
self.sync_animation_timer(now);
changed
}
@@ -232,6 +243,10 @@ impl App {
false
}
+ pub(crate) fn sync_agent_metadata_deadline(&mut self) {
+ self.agent_metadata_deadline = self.state.next_agent_metadata_expiry();
+ }
+
pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
}
@@ -444,6 +459,7 @@ impl App {
self.next_animation_tick,
self.git_refresh_deadline(),
self.next_auto_update_check,
+ self.agent_metadata_deadline,
self.session_save_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 23218a4a..d759fa0d 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1,7 +1,7 @@
use crate::api::schema::{
Method, PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams,
- PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget,
- ReadFormat, ReadSource, Request,
+ PaneReportMetadataParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams,
+ PaneSplitParams, PaneTarget, ReadFormat, ReadSource, Request,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -20,6 +20,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
"send-text" => pane_send_text(&args[1..]),
"send-keys" => pane_send_keys(&args[1..]),
"report-agent" => pane_report_agent(&args[1..]),
+ "report-metadata" => pane_report_metadata(&args[1..]),
"run" => pane_run(&args[1..]),
"help" | "--help" | "-h" => {
print_pane_help();
@@ -387,7 +388,10 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
}
}
- let Some(source) = source else {
+ let Some(source) = source.and_then(|source| {
+ let source = source.trim().to_string();
+ (!source.is_empty()).then_some(source)
+ }) else {
eprintln!("missing required --source");
return Ok(2);
};
@@ -413,6 +417,190 @@ fn pane_report_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]");
+ return Ok(2);
+ };
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ let mut source = None;
+ let mut agent = None;
+ 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 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;
+
+ 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;
+ }
+ "--agent" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent");
+ return Ok(2);
+ };
+ agent = Some(value.clone());
+ index += 2;
+ }
+ "--applies-to-source" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --applies-to-source");
+ return Ok(2);
+ };
+ applies_to_source = Some(value.clone());
+ index += 2;
+ }
+ "--title" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --title");
+ return Ok(2);
+ };
+ title = Some(value.clone());
+ index += 2;
+ }
+ "--clear-title" => {
+ clear_title = true;
+ index += 1;
+ }
+ "--display-agent" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --display-agent");
+ return Ok(2);
+ };
+ display_agent = Some(value.clone());
+ index += 2;
+ }
+ "--clear-display-agent" => {
+ 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");
+ return Ok(2);
+ };
+ let Some((status, label)) = value.split_once('=') else {
+ eprintln!("expected --state-label STATUS=TEXT");
+ return Ok(2);
+ };
+ let status = status.trim().to_ascii_lowercase();
+ if !matches!(
+ status.as_str(),
+ "idle" | "working" | "blocked" | "done" | "unknown"
+ ) {
+ eprintln!("unknown state label: {status}");
+ return Ok(2);
+ }
+ state_labels.insert(status, label.to_string());
+ index += 2;
+ }
+ "--clear-state-labels" => {
+ clear_state_labels = true;
+ index += 1;
+ }
+ "--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.and_then(|source| {
+ let source = source.trim().to_string();
+ (!source.is_empty()).then_some(source)
+ }) else {
+ eprintln!("missing required --source");
+ return Ok(2);
+ };
+ if applies_to_source
+ .as_deref()
+ .is_some_and(|source| source.trim().is_empty())
+ {
+ eprintln!("missing value for --applies-to-source");
+ return Ok(2);
+ }
+ 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");
+ return Ok(2);
+ }
+ if title.is_none()
+ && display_agent.is_none()
+ && custom_status.is_none()
+ && state_labels.is_empty()
+ && !clear_title
+ && !clear_display_agent
+ && !clear_custom_status
+ && !clear_state_labels
+ {
+ eprintln!("missing metadata field to set or clear");
+ return Ok(2);
+ }
+
+ super::send_ok_request(Method::PaneReportMetadata(PaneReportMetadataParams {
+ pane_id,
+ source,
+ agent,
+ applies_to_source,
+ title,
+ display_agent,
+ custom_status,
+ state_labels,
+ clear_title,
+ clear_display_agent,
+ clear_custom_status,
+ clear_state_labels,
+ seq,
+ ttl_ms,
+ }))
+}
+
fn print_pane_help() {
eprintln!("herdr pane commands:");
eprintln!(" herdr pane list [--workspace <workspace_id>]");
@@ -426,5 +614,6 @@ fn print_pane_help() {
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-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 run <pane_id> <command>");
}
diff --git a/src/events.rs b/src/events.rs
index 49c22ec9..4bbfb219 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -49,6 +49,23 @@ pub enum AppEvent {
seq: Option<u64>,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
},
+ /// Display-only agent metadata was reported for a pane.
+ HookMetadataReported {
+ pane_id: PaneId,
+ source: String,
+ agent_label: Option<String>,
+ 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>,
+ },
/// Hook authority was explicitly cleared for a pane.
HookAuthorityCleared {
pane_id: PaneId,
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
index b76d98e7..6486d457 100644
--- a/src/integration/assets/claude/herdr-agent-state.sh
+++ b/src/integration/assets/claude/herdr-agent-state.sh
@@ -1,8 +1,9 @@
#!/bin/sh
# installed by herdr
-# safe to edit. this hook only activates inside herdr-managed panes.
+# managed by herdr; reinstalling or updating the integration overwrites this file.
+# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=claude
-# HERDR_INTEGRATION_VERSION=4
+# HERDR_INTEGRATION_VERSION=5
set -eu
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
index 0f0979e3..c8a397e5 100644
--- a/src/integration/assets/codex/herdr-agent-state.sh
+++ b/src/integration/assets/codex/herdr-agent-state.sh
@@ -1,8 +1,9 @@
#!/bin/sh
# installed by herdr
-# safe to edit. this hook only activates inside herdr-managed panes.
+# managed by herdr; reinstalling or updating the integration overwrites this file.
+# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=codex
-# HERDR_INTEGRATION_VERSION=4
+# HERDR_INTEGRATION_VERSION=5
set -eu
diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/src/integration/assets/omp/herdr-agent-state.ts
index 64c4b02a..9229436b 100644
--- a/src/integration/assets/omp/herdr-agent-state.ts
+++ b/src/integration/assets/omp/herdr-agent-state.ts
@@ -1,7 +1,8 @@
// installed by herdr
-// safe to edit. this integration only activates inside herdr-managed panes.
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=omp
-// HERDR_INTEGRATION_VERSION=1
+// HERDR_INTEGRATION_VERSION=2
// @ts-nocheck
import { createConnection } from "node:net";
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 778fe564..a6660f09 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -1,7 +1,8 @@
// installed by herdr
-// safe to edit. this plugin only activates inside herdr-managed panes.
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=opencode
-// HERDR_INTEGRATION_VERSION=2
+// HERDR_INTEGRATION_VERSION=3
import net from "node:net";
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
index 450fd48c..fef4b489 100644
--- a/src/integration/assets/pi/herdr-agent-state.ts
+++ b/src/integration/assets/pi/herdr-agent-state.ts
@@ -1,7 +1,8 @@
// installed by herdr
-// safe to edit. this integration only activates inside herdr-managed panes.
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=pi
-// HERDR_INTEGRATION_VERSION=2
+// HERDR_INTEGRATION_VERSION=3
// @ts-nocheck
import { createConnection } from "node:net";
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index d9abf648..717107c2 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -12,22 +12,22 @@ use crate::layout::PaneId;
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
-const PI_INTEGRATION_VERSION: u32 = 2;
+const PI_INTEGRATION_VERSION: u32 = 3;
const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts";
const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts");
-const OMP_INTEGRATION_VERSION: u32 = 1;
+const OMP_INTEGRATION_VERSION: u32 = 2;
const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
-const CLAUDE_INTEGRATION_VERSION: u32 = 4;
+const CLAUDE_INTEGRATION_VERSION: u32 = 5;
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
-const CODEX_INTEGRATION_VERSION: u32 = 4;
+const CODEX_INTEGRATION_VERSION: u32 = 5;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
-const OPENCODE_INTEGRATION_VERSION: u32 = 2;
+const OPENCODE_INTEGRATION_VERSION: u32 = 3;
const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state";
const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
@@ -2090,7 +2090,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(1));
- assert_eq!(claude.expected_version, 4);
+ assert_eq!(claude.expected_version, 5);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2120,7 +2120,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(2));
- assert_eq!(claude.expected_version, 4);
+ assert_eq!(claude.expected_version, 5);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2224,7 +2224,7 @@ mod tests {
assert_eq!(codex.path, hook_path);
assert_eq!(codex.installed_version, Some(2));
- assert_eq!(codex.expected_version, 4);
+ assert_eq!(codex.expected_version, 5);
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
diff --git a/src/logging.rs b/src/logging.rs
index 2754062b..2c64fbce 100644
--- a/src/logging.rs
+++ b/src/logging.rs
@@ -111,6 +111,7 @@ fn is_routine_api_method(method: &str) -> bool {
| "workspace.list"
| "tab.list"
| "pane.report_agent"
+ | "pane.report_metadata"
)
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index e65b741c..37c05bf0 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2479,6 +2479,18 @@ impl HeadlessServer {
self.app.save_session_now();
}
+ if let Some(deadline) = self
+ .app
+ .agent_metadata_deadline
+ .filter(|deadline| now >= *deadline)
+ {
+ for update in self.app.state.expire_agent_metadata_at(deadline, now) {
+ self.app.emit_pane_state_update(&update);
+ }
+ self.app.sync_agent_metadata_deadline();
+ changed = true;
+ }
+
self.app.sync_headless_animation_timer(now);
changed
}
@@ -3452,6 +3464,96 @@ next_tab = ""
}
#[test]
+ fn headless_scheduled_tasks_expire_agent_metadata() {
+ let mut server = test_headless_server();
+ let workspace = crate::workspace::Workspace::test_new("metadata");
+ let pane_id = workspace.tabs[0].root_pane;
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.ensure_test_terminals();
+
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent_label: "pi".into(),
+ state: crate::detect::AgentState::Working,
+ message: None,
+ custom_status: None,
+ seq: None,
+ session_ref: None,
+ })
+ );
+ assert!(
+ server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported {
+ pane_id,
+ source: "user:pi-display".into(),
+ agent_label: Some("pi".into()),
+ applies_to_source: Some("herdr:pi".into()),
+ title: None,
+ 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)),
+ })
+ );
+
+ let deadline = server
+ .app
+ .agent_metadata_deadline
+ .expect("metadata deadline");
+ let terminal_id = server.app.state.workspaces[0]
+ .pane_state(pane_id)
+ .expect("pane")
+ .attached_terminal_id
+ .clone();
+ assert_eq!(
+ server
+ .app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("terminal")
+ .effective_custom_status()
+ .as_deref(),
+ Some("short lived")
+ );
+
+ assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1)));
+
+ assert_eq!(server.app.agent_metadata_deadline, None);
+ assert_eq!(
+ server
+ .app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("terminal")
+ .effective_custom_status(),
+ None
+ );
+ assert!(server
+ .app
+ .event_hub
+ .events_after(0)
+ .iter()
+ .any(|(_, event)| {
+ event.event == crate::api::schema::EventKind::PaneAgentStatusChanged
+ && matches!(
+ &event.data,
+ crate::api::schema::EventData::PaneAgentStatusChanged {
+ custom_status,
+ ..
+ } if custom_status.is_none()
+ )
+ }));
+ }
+
+ #[test]
fn virtual_render_produces_nonempty_buffer() {
let mut state = AppState::test_new();
let area = Rect::new(0, 0, 80, 24);
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs
new file mode 100644
index 00000000..5b9f0078
--- /dev/null
+++ b/src/terminal/metadata.rs
@@ -0,0 +1,1282 @@
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use crate::detect::AgentState;
+
+use super::{TerminalState, TerminalStateMutation};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentMetadata {
+ pub source: String,
+ pub agent_label: Option<String>,
+ 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,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentMetadataReport {
+ pub source: String,
+ pub agent_label: Option<String>,
+ 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>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EffectivePresentation {
+ pub title: Option<String>,
+ pub display_agent: Option<String>,
+ pub custom_status: Option<String>,
+ pub state_labels: HashMap<String, String>,
+}
+
+impl EffectivePresentation {
+ fn empty() -> Self {
+ 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 {
+ let Some(seq) = seq else {
+ return true;
+ };
+
+ if self
+ .metadata_report_sequences
+ .get(source)
+ .is_some_and(|last_seq| seq <= *last_seq)
+ {
+ return false;
+ }
+
+ self.metadata_report_sequences
+ .insert(source.to_string(), seq);
+ true
+ }
+
+ pub fn set_agent_metadata(
+ &mut self,
+ report: AgentMetadataReport,
+ ) -> Option<TerminalStateMutation> {
+ if !self.accept_metadata_report(&report.source, report.seq) {
+ return None;
+ }
+
+ let now = Instant::now();
+ if self
+ .agent_metadata
+ .get(&report.source)
+ .is_some_and(|metadata| self.agent_metadata_is_expired(metadata, now))
+ {
+ self.agent_metadata.remove(&report.source);
+ }
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ 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
+ {
+ let metadata = self
+ .agent_metadata
+ .entry(report.source.clone())
+ .or_insert_with(|| AgentMetadata {
+ source: report.source.clone(),
+ agent_label: report.agent_label.clone(),
+ 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,
+ });
+ if report.clear_title {
+ metadata.title = None;
+ metadata.title_reported_at = None;
+ }
+ if report.clear_display_agent {
+ 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();
+ }
+ if let Some(agent_label) = report.agent_label {
+ metadata.agent_label = Some(agent_label);
+ }
+ if let Some(applies_to_source) = report.applies_to_source {
+ metadata.applies_to_source = Some(applies_to_source);
+ }
+ if let Some(title) = report.title {
+ metadata.title = Some(title);
+ metadata.title_reported_at = Some(now);
+ }
+ if let Some(display_agent) = report.display_agent {
+ 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);
+ }
+ if has_set_fields || report.ttl.is_some() {
+ metadata.reported_at = now;
+ metadata.ttl = report.ttl;
+ metadata.expiry_event_pending = false;
+ }
+ } 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()
+ .map(|state| (state.clone(), now))
+ .collect();
+ self.agent_metadata.insert(
+ report.source.clone(),
+ AgentMetadata {
+ source: report.source,
+ agent_label: report.agent_label,
+ 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,
+ },
+ );
+ }
+
+ if report_has_ttl
+ && self
+ .agent_metadata
+ .get(&report_source)
+ .is_some_and(|metadata| self.agent_metadata_is_visible_ignoring_ttl(metadata))
+ {
+ if let Some(metadata) = self.agent_metadata.get_mut(&report_source) {
+ metadata.expiry_event_pending = true;
+ }
+ }
+
+ let effective_state_change = self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ previous_presentation,
+ now,
+ );
+
+ if effective_state_change.is_some() {
+ self.clear_expiry_pending_for_hidden_metadata();
+ }
+
+ Some(TerminalStateMutation {
+ effective_state_change,
+ 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
+ }
+
+ pub fn effective_display_agent(&self) -> Option<String> {
+ self.effective_presentation_for_state_at(self.state, Instant::now())
+ .display_agent
+ }
+
+ pub fn effective_presentation(&self) -> EffectivePresentation {
+ self.effective_presentation_for_state_at(self.state, Instant::now())
+ }
+
+ pub fn next_agent_metadata_expiry(&self) -> Option<Instant> {
+ let now = Instant::now();
+ self.agent_metadata
+ .values()
+ .filter(|metadata| self.agent_metadata_matches_guards(metadata))
+ .filter_map(|metadata| self.agent_metadata_expiry(metadata))
+ .filter(|deadline| {
+ *deadline > now
+ || self.agent_metadata.values().any(|metadata| {
+ metadata.expiry_event_pending
+ && self.agent_metadata_matches_guards(metadata)
+ && self.agent_metadata_expiry(metadata) == Some(*deadline)
+ })
+ })
+ .min()
+ }
+
+ pub fn expire_agent_metadata_at(
+ &mut self,
+ scheduled_deadline: Instant,
+ now: Instant,
+ ) -> Option<TerminalStateMutation> {
+ let (expired_sources, stale_sources): (Vec<_>, Vec<_>) = self
+ .agent_metadata
+ .iter()
+ .filter_map(|(source, metadata)| {
+ let deadline = self.agent_metadata_expiry(metadata)?;
+ (deadline <= now).then_some((source.clone(), deadline))
+ })
+ .partition(|(_, deadline)| *deadline >= scheduled_deadline);
+ let expired_sources: Vec<_> = expired_sources
+ .into_iter()
+ .map(|(source, _)| source)
+ .collect();
+ let stale_sources: Vec<_> = stale_sources
+ .into_iter()
+ .map(|(source, _)| source)
+ .collect();
+ for source in stale_sources {
+ self.agent_metadata.remove(&source);
+ }
+ if expired_sources.is_empty() {
+ return None;
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ let previous_presentation =
+ self.effective_presentation_for_state_at_ignoring_ttl(previous_state, now);
+ for source in expired_sources {
+ if let Some(metadata) = self.agent_metadata.get_mut(&source) {
+ metadata.expiry_event_pending = false;
+ }
+ self.agent_metadata.remove(&source);
+ }
+
+ Some(TerminalStateMutation {
+ effective_state_change: self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ previous_presentation,
+ now,
+ ),
+ session_ref_changed: false,
+ })
+ }
+
+ pub(super) fn effective_presentation_for_state_at(
+ &self,
+ state: AgentState,
+ now: Instant,
+ ) -> EffectivePresentation {
+ self.effective_presentation_for_state_at_with_ttl(state, now, true)
+ }
+
+ fn effective_presentation_for_state_at_ignoring_ttl(
+ &self,
+ state: AgentState,
+ now: Instant,
+ ) -> EffectivePresentation {
+ self.effective_presentation_for_state_at_with_ttl(state, now, false)
+ }
+
+ fn effective_presentation_for_state_at_with_ttl(
+ &self,
+ state: AgentState,
+ now: Instant,
+ enforce_ttl: bool,
+ ) -> EffectivePresentation {
+ let mut presentation = EffectivePresentation::empty();
+ 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()
+ || self.visible_idle_masks_hook_custom_status(state, now)
+ {
+ return None;
+ }
+
+ self.hook_authority
+ .as_ref()
+ .and_then(|authority| authority.custom_status.clone())
+ }
+
+ fn valid_agent_metadata(
+ &self,
+ now: Instant,
+ enforce_ttl: bool,
+ ) -> impl Iterator<Item = &AgentMetadata> {
+ self.agent_metadata
+ .values()
+ .filter(move |metadata| self.agent_metadata_is_valid(metadata, now, enforce_ttl))
+ }
+
+ fn newest_metadata_title(&self, now: Instant, enforce_ttl: bool) -> Option<String> {
+ self.valid_agent_metadata(now, enforce_ttl)
+ .filter(|metadata| metadata.title.is_some())
+ .max_by_key(|metadata| metadata.title_reported_at)
+ .and_then(|metadata| metadata.title.clone())
+ }
+
+ fn newest_metadata_display_agent(&self, now: Instant, enforce_ttl: bool) -> Option<String> {
+ self.valid_agent_metadata(now, enforce_ttl)
+ .filter(|metadata| metadata.display_agent.is_some())
+ .max_by_key(|metadata| metadata.display_agent_reported_at)
+ .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,
+ enforce_ttl: bool,
+ ) -> HashMap<String, String> {
+ let mut labels: Vec<_> = self
+ .valid_agent_metadata(now, enforce_ttl)
+ .flat_map(|metadata| {
+ metadata.state_labels.iter().filter_map(|(state, label)| {
+ Some((
+ *metadata.state_label_reported_at.get(state)?,
+ state.clone(),
+ label.clone(),
+ ))
+ })
+ })
+ .collect();
+ labels.sort_by_key(|(reported_at, _, _)| *reported_at);
+ labels
+ .into_iter()
+ .map(|(_, state, label)| (state, label))
+ .collect()
+ }
+
+ fn agent_metadata_is_valid(
+ &self,
+ metadata: &AgentMetadata,
+ now: Instant,
+ enforce_ttl: bool,
+ ) -> bool {
+ if metadata.title.is_none()
+ && metadata.display_agent.is_none()
+ && metadata.custom_status.is_none()
+ && metadata.state_labels.is_empty()
+ {
+ return false;
+ }
+ if enforce_ttl && self.agent_metadata_is_expired(metadata, now) {
+ return false;
+ }
+ self.agent_metadata_matches_guards(metadata)
+ }
+
+ 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)
+ }
+
+ pub(super) fn clear_expiry_pending_for_hidden_metadata(&mut self) {
+ let hidden_sources: Vec<_> = self
+ .agent_metadata
+ .iter()
+ .filter(|(_, metadata)| {
+ metadata.expiry_event_pending
+ && !self.agent_metadata_is_visible_ignoring_ttl(metadata)
+ })
+ .map(|(source, _)| source.clone())
+ .collect();
+ for source in hidden_sources {
+ if let Some(metadata) = self.agent_metadata.get_mut(&source) {
+ metadata.expiry_event_pending = false;
+ }
+ }
+ }
+
+ fn agent_metadata_is_expired(&self, metadata: &AgentMetadata, now: Instant) -> bool {
+ self.agent_metadata_expiry(metadata)
+ .is_some_and(|deadline| now >= deadline)
+ }
+
+ fn agent_metadata_expiry(&self, metadata: &AgentMetadata) -> Option<Instant> {
+ metadata.ttl.map(|ttl| {
+ metadata
+ .reported_at
+ .checked_add(ttl)
+ .unwrap_or(metadata.reported_at)
+ })
+ }
+
+ 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
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use super::*;
+ use crate::detect::Agent;
+ use crate::terminal::TerminalId;
+
+ fn test_terminal() -> TerminalState {
+ 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() {
+ 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);
+
+ assert_eq!(
+ terminal.effective_custom_status().as_deref(),
+ Some("thinking")
+ );
+ }
+
+ #[test]
+ fn user_agent_metadata_overrides_presentation_fields_only() {
+ 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 = terminal.set_agent_metadata(AgentMetadataReport {
+ source: "user:presentation".into(),
+ agent_label: Some("claude".into()),
+ 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,
+ });
+
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(terminal.effective_agent_label(), Some("claude"));
+ 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")
+ );
+ assert!(mutation.unwrap().effective_state_change.is_some());
+ }
+
+ #[test]
+ fn metadata_title_takes_pane_border_priority() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle);
+ terminal.set_manual_label("manual".into());
+ terminal.set_agent_metadata(AgentMetadataReport {
+ source: "user:presentation".into(),
+ agent_label: Some("claude".into()),
+ 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,
+ });
+
+ assert_eq!(
+ terminal.border_label(false).as_deref(),
+ Some("Prompt title")
+ );
+ assert_eq!(terminal.border_label(true).as_deref(), Some("Prompt title"));
+ }
+
+ #[test]
+ fn metadata_without_sequence_can_update_same_source() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ 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,
+ );
+
+ assert_eq!(
+ terminal.effective_custom_status().as_deref(),
+ Some("second")
+ );
+ }
+
+ #[test]
+ fn metadata_resolves_newest_value_per_presentation_field() {
+ 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:title".into(),
+ agent_label: Some("claude".into()),
+ 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),
+ });
+ 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: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ ttl: None,
+ seq: Some(1),
+ });
+
+ let presentation = terminal.effective_presentation();
+ assert_eq!(presentation.title.as_deref(), Some("Prompt title"));
+ assert_eq!(presentation.custom_status.as_deref(), Some("activity"));
+ }
+
+ #[test]
+ fn partial_update_does_not_refresh_unchanged_field_precedence() {
+ 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:first".into(),
+ agent_label: Some("claude".into()),
+ 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),
+ });
+ terminal.set_agent_metadata(AgentMetadataReport {
+ source: "user:first".into(),
+ agent_label: Some("claude".into()),
+ 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),
+ });
+
+ 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]
+ fn metadata_clear_plus_set_without_ttl_does_not_keep_old_ttl() {
+ 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: 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,
+ });
+ let old_deadline = terminal.next_agent_metadata_expiry().unwrap();
+
+ 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("fresh".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.next_agent_metadata_expiry(), None);
+ assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh"));
+ 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"));
+ }
+
+ #[test]
+ fn metadata_clear_only_without_ttl_does_not_extend_old_ttl() {
+ 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: Some("Prompt 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,
+ });
+ let old_deadline = terminal.next_agent_metadata_expiry().unwrap();
+
+ terminal.set_agent_metadata(AgentMetadataReport {
+ source: "user:status".into(),
+ agent_label: None,
+ 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_state_labels: false,
+ ttl: None,
+ seq: None,
+ });
+
+ assert_eq!(terminal.next_agent_metadata_expiry(), Some(old_deadline));
+ assert_eq!(
+ terminal.effective_presentation().title.as_deref(),
+ Some("Prompt title")
+ );
+ assert_eq!(terminal.effective_custom_status(), None);
+
+ let mutation = terminal
+ .expire_agent_metadata_at(old_deadline, old_deadline)
+ .unwrap();
+ assert!(mutation.effective_state_change.is_some());
+ assert_eq!(terminal.effective_presentation().title, None);
+ }
+
+ #[test]
+ fn metadata_ttl_expiry_reports_presentation_change() {
+ 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: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ ttl: Some(Duration::from_millis(1)),
+ seq: None,
+ });
+
+ let deadline = terminal.next_agent_metadata_expiry().unwrap();
+ let mutation = terminal
+ .expire_agent_metadata_at(deadline, deadline)
+ .unwrap();
+ let change = mutation.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("activity")
+ );
+ assert_eq!(change.presentation.custom_status, None);
+ assert_eq!(terminal.effective_custom_status(), None);
+ }
+
+ #[test]
+ fn stale_guarded_metadata_expiry_does_not_report_visible_change() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".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("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,
+ });
+ let deadline = terminal
+ .agent_metadata
+ .get("user:status")
+ .and_then(|metadata| terminal.agent_metadata_expiry(metadata))
+ .unwrap();
+ assert_eq!(terminal.next_agent_metadata_expiry(), None);
+ assert_eq!(terminal.effective_custom_status(), None);
+
+ terminal.set_hook_authority(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+ let mutation = terminal.expire_agent_metadata_at(
+ deadline + Duration::from_millis(1),
+ deadline + Duration::from_millis(1),
+ );
+
+ assert!(mutation.is_none());
+ assert!(!terminal.agent_metadata.contains_key("user:status"));
+ assert_eq!(terminal.effective_custom_status(), None);
+ }
+
+ #[test]
+ fn late_metadata_expiry_reports_all_due_visible_changes() {
+ 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:first".into(),
+ agent_label: Some("claude".into()),
+ 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,
+ });
+ let first_deadline = terminal.next_agent_metadata_expiry().unwrap();
+ 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: 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,
+ });
+ let second_deadline = terminal
+ .agent_metadata
+ .get("user:second")
+ .and_then(|metadata| terminal.agent_metadata_expiry(metadata))
+ .unwrap();
+
+ let mutation = terminal
+ .expire_agent_metadata_at(first_deadline, second_deadline)
+ .unwrap();
+ let change = mutation.effective_state_change.unwrap();
+
+ assert_eq!(change.previous_presentation.title.as_deref(), Some("First"));
+ assert_eq!(
+ change.previous_presentation.display_agent.as_deref(),
+ Some("Second")
+ );
+ assert_eq!(change.presentation, EffectivePresentation::empty());
+ assert!(terminal.agent_metadata.is_empty());
+ }
+
+ #[test]
+ fn immediately_expired_visible_metadata_still_schedules_expiry_event() {
+ 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("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,
+ });
+
+ let deadline = terminal
+ .next_agent_metadata_expiry()
+ .expect("pending expiry should be scheduled");
+ let mutation = terminal
+ .expire_agent_metadata_at(deadline, Instant::now())
+ .unwrap();
+ let change = mutation.effective_state_change.unwrap();
+
+ assert_eq!(
+ change.previous_presentation.custom_status.as_deref(),
+ Some("instant")
+ );
+ assert_eq!(change.presentation.custom_status, None);
+ assert_eq!(terminal.effective_custom_status(), None);
+ }
+
+ #[test]
+ fn pending_metadata_expiry_clears_when_lifecycle_guard_hides_metadata() {
+ 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("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,
+ });
+ assert!(terminal.next_agent_metadata_expiry().is_some());
+
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+ terminal.set_hook_authority(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ assert_eq!(terminal.next_agent_metadata_expiry(), None);
+ assert_eq!(terminal.effective_custom_status(), None);
+ }
+
+ #[test]
+ fn partial_update_does_not_resurrect_expired_hidden_metadata_fields() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".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: 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,
+ });
+ assert_eq!(terminal.next_agent_metadata_expiry(), 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: 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,
+ });
+ terminal.set_hook_authority(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ let presentation = terminal.effective_presentation();
+ assert_eq!(presentation.title, None);
+ assert_eq!(presentation.display_agent.as_deref(), Some("Fresh display"));
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 663cb6fe..03432a56 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -6,4 +6,7 @@ pub mod state;
pub use id::TerminalId;
pub use runtime::TerminalRuntime;
pub(crate) use runtime_registry::TerminalRuntimeRegistry;
-pub use state::{EffectiveStateChange, TerminalState, TerminalStateMutation};
+pub use state::{
+ AgentMetadataReport, EffectivePresentation, EffectiveStateChange, TerminalState,
+ TerminalStateMutation,
+};
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index b94be3e7..d713118c 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -11,6 +11,10 @@ use std::time::{Duration, Instant};
use crate::detect::{Agent, AgentState};
use crate::terminal::TerminalId;
+#[path = "metadata.rs"]
+mod metadata;
+pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation};
+
const CLAUDE_WORKING_HOLD: Duration = Duration::from_millis(1200);
const STALE_HOOK_IDLE_GRACE: Duration = Duration::from_secs(2);
@@ -30,10 +34,11 @@ pub struct EffectiveStateChange {
pub previous_agent_label: Option<String>,
pub previous_known_agent: Option<Agent>,
pub previous_state: AgentState,
+ pub previous_presentation: EffectivePresentation,
pub agent_label: Option<String>,
pub known_agent: Option<Agent>,
pub state: AgentState,
- pub custom_status: Option<String>,
+ pub presentation: EffectivePresentation,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -58,10 +63,12 @@ pub struct TerminalState {
fallback_observed_at: Option<Instant>,
stale_hook_idle_since: Option<Instant>,
pub hook_authority: Option<HookAuthority>,
+ pub agent_metadata: HashMap<String, AgentMetadata>,
pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>,
pub manual_label: Option<String>,
pub agent_name: Option<String>,
hook_report_sequences: HashMap<String, u64>,
+ metadata_report_sequences: HashMap<String, u64>,
pub state: AgentState,
pub revision: u64,
pub launch_argv: Option<Vec<String>>,
@@ -80,10 +87,12 @@ impl TerminalState {
fallback_observed_at: None,
stale_hook_idle_since: None,
hook_authority: None,
+ agent_metadata: HashMap::new(),
persisted_agent_session: None,
manual_label: None,
agent_name: None,
hook_report_sequences: HashMap::new(),
+ metadata_report_sequences: HashMap::new(),
state: AgentState::Unknown,
revision: 0,
launch_argv: None,
@@ -155,6 +164,7 @@ impl TerminalState {
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
+ let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_detected_agent = self.detected_agent;
let previous_session = self.current_session_identity_for_persistence();
self.detected_agent = agent;
@@ -199,6 +209,7 @@ impl TerminalState {
previous_agent_label,
previous_known_agent,
previous_state,
+ previous_presentation,
now,
),
session_ref_changed: previous_session
@@ -281,6 +292,7 @@ impl TerminalState {
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
+ let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
return None;
@@ -302,6 +314,7 @@ impl TerminalState {
previous_agent_label,
previous_known_agent,
previous_state,
+ previous_presentation,
now,
),
session_ref_changed: previous_session != current_session,
@@ -449,9 +462,11 @@ impl TerminalState {
}
}
+ let now = Instant::now();
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
+ let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
let should_clear = self
.hook_authority
@@ -463,12 +478,12 @@ impl TerminalState {
self.hook_authority = None;
self.stale_hook_idle_since = None;
self.persisted_agent_session = None;
- let now = Instant::now();
Some(TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
previous_agent_label,
previous_known_agent,
previous_state,
+ previous_presentation,
now,
),
session_ref_changed: previous_session.is_some(),
@@ -508,9 +523,11 @@ impl TerminalState {
return None;
}
+ let now = Instant::now();
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
+ let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
self.detected_agent = None;
self.fallback_state = AgentState::Unknown;
@@ -521,12 +538,12 @@ impl TerminalState {
self.hook_authority = None;
self.stale_hook_idle_since = None;
self.persisted_agent_session = None;
- let now = Instant::now();
Some(TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
previous_agent_label,
previous_known_agent,
previous_state,
+ previous_presentation,
now,
),
session_ref_changed: previous_session.is_some(),
@@ -547,18 +564,6 @@ impl TerminalState {
self.detected_agent
}
- pub fn effective_custom_status(&self) -> Option<&str> {
- if self.visible_blocker_overrides_hook()
- || self.visible_idle_masks_hook_custom_status(self.state, Instant::now())
- {
- return None;
- }
-
- self.hook_authority
- .as_ref()
- .and_then(|authority| authority.custom_status.as_deref())
- }
-
fn visible_blocker_overrides_hook(&self) -> bool {
self.fallback_visible_blocker
&& self.fallback_not_older_than_hook()
@@ -635,11 +640,16 @@ impl TerminalState {
|| self.launch_argv.is_some()
}
- pub fn border_label(&self, show_agent_labels: bool) -> Option<&str> {
- self.manual_label.as_deref().or_else(|| {
- show_agent_labels
- .then(|| self.effective_agent_label())
- .flatten()
+ pub fn border_label(&self, show_agent_labels: bool) -> Option<String> {
+ self.effective_title().or_else(|| {
+ self.manual_label.clone().or_else(|| {
+ show_agent_labels
+ .then(|| {
+ self.effective_display_agent()
+ .or_else(|| self.effective_agent_label().map(str::to_string))
+ })
+ .flatten()
+ })
})
}
@@ -648,6 +658,7 @@ impl TerminalState {
previous_agent_label: Option<String>,
previous_known_agent: Option<Agent>,
previous_state: AgentState,
+ previous_presentation: EffectivePresentation,
now: Instant,
) -> Option<EffectiveStateChange> {
let state = if self
@@ -670,18 +681,13 @@ impl TerminalState {
let agent_label = self.effective_agent_label().map(str::to_string);
let known_agent = self.effective_known_agent();
- let custom_status = if self.visible_blocker_overrides_hook()
- || self.visible_idle_masks_hook_custom_status(state, now)
- {
- None
- } else {
- self.hook_authority
- .as_ref()
- .and_then(|authority| authority.custom_status.as_deref())
- .map(str::to_string)
- };
+ let presentation = self.effective_presentation_for_state_at(state, now);
+ self.clear_expiry_pending_for_hidden_metadata();
- if previous_agent_label == agent_label && previous_state == state {
+ if previous_agent_label == agent_label
+ && previous_state == state
+ && previous_presentation == presentation
+ {
return None;
}
@@ -690,10 +696,11 @@ impl TerminalState {
previous_agent_label,
previous_known_agent,
previous_state,
+ previous_presentation,
agent_label,
known_agent,
state,
- custom_status,
+ presentation,
})
}
}
@@ -1052,7 +1059,10 @@ mod tests {
assert!(waiting.effective_state_change.is_none());
assert_eq!(terminal.fallback_state, AgentState::Idle);
assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(terminal.effective_custom_status(), Some("thinking"));
+ assert_eq!(
+ terminal.effective_custom_status().as_deref(),
+ Some("thinking")
+ );
let change = terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Claude),
@@ -1248,18 +1258,18 @@ mod tests {
terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle);
assert_eq!(terminal.border_label(false), None);
- assert_eq!(terminal.border_label(true), Some("claude"));
+ assert_eq!(terminal.border_label(true).as_deref(), Some("claude"));
terminal.set_manual_label(" reviewer ".into());
- assert_eq!(terminal.border_label(false), Some("reviewer"));
- assert_eq!(terminal.border_label(true), Some("reviewer"));
+ assert_eq!(terminal.border_label(false).as_deref(), Some("reviewer"));
+ assert_eq!(terminal.border_label(true).as_deref(), Some("reviewer"));
terminal.set_manual_label(" ".into());
- assert_eq!(terminal.border_label(true), Some("claude"));
+ assert_eq!(terminal.border_label(true).as_deref(), Some("claude"));
terminal.set_manual_label("reviewer".into());
terminal.clear_manual_label();
- assert_eq!(terminal.border_label(true), Some("claude"));
+ assert_eq!(terminal.border_label(true).as_deref(), Some("claude"));
}
#[test]
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index 89557642..2de79d23 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -622,7 +622,15 @@ fn mobile_agent_detail(entry: &AgentPanelEntry) -> String {
if let Some(tab_label) = entry.primary_tab_label.as_deref() {
parts.push(tab_label.to_string());
}
- parts.push(super::status::state_label(entry.state, entry.seen).to_string());
+ let status = entry
+ .state_labels
+ .get(super::sidebar::agent_panel_status_key(
+ entry.state,
+ entry.seen,
+ ))
+ .cloned()
+ .unwrap_or_else(|| super::status::state_label(entry.state, entry.seen).to_string());
+ parts.push(status);
if let Some(agent_label) = entry.agent_label.as_deref() {
parts.push(agent_label.to_string());
}
@@ -940,6 +948,7 @@ mod tests {
state: AgentState::Idle,
seen: true,
custom_status: None,
+ state_labels: std::collections::HashMap::new(),
}
}
diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs
index 5cb208ae..b6bc1f19 100644
--- a/src/ui/navigator.rs
+++ b/src/ui/navigator.rs
@@ -362,20 +362,30 @@ fn pane_detail(
}
if let Some(terminal_id) = tab.terminal_id(pane_id) {
if let Some(terminal) = app.terminals.get(terminal_id) {
- if let Some(agent) = terminal
- .agent_name
- .as_deref()
- .or_else(|| terminal.effective_agent_label())
- {
+ let presentation = terminal.effective_presentation();
+ if let Some(title) = presentation.title {
+ parts.push(title);
+ }
+ let display_agent = terminal.effective_display_agent();
+ if let Some(agent) = display_agent.as_deref().or_else(|| {
+ terminal
+ .agent_name
+ .as_deref()
+ .or_else(|| terminal.effective_agent_label())
+ }) {
parts.push(agent.to_string());
let seen = tab
.panes
.get(&pane_id)
.map(|pane| pane.seen)
.unwrap_or(true);
- parts.push(
- display_state(row_state(app, ws_idx, tab_idx, pane_id), seen).to_string(),
- );
+ let state = row_state(app, ws_idx, tab_idx, pane_id);
+ let status = presentation
+ .state_labels
+ .get(display_state(state, seen))
+ .cloned()
+ .unwrap_or_else(|| display_state(state, seen).to_string());
+ parts.push(status);
} else {
parts.push("shell".to_string());
}
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index a1729cd6..c211ec9d 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -282,7 +282,7 @@ pub(super) fn render_panes(
.and_then(|terminal| {
terminal.border_label(app.show_agent_labels_on_pane_borders)
})
- .and_then(|label| pane_border_title(label, info.rect.width))
+ .and_then(|label| pane_border_title(&label, info.rect.width))
{
block = block.title(Line::from(Span::styled(title, border_style)));
}
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 1e932503..4c68aeb4 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -26,6 +26,7 @@ pub(crate) struct AgentPanelEntry {
pub state: AgentState,
pub seen: bool,
pub custom_status: Option<String>,
+ pub state_labels: std::collections::HashMap<String, String>,
}
fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
@@ -153,6 +154,7 @@ fn agent_panel_entries_with_runtimes(
state: detail.state,
seen: detail.seen,
custom_status: detail.custom_status,
+ state_labels: detail.state_labels,
})
.collect()
}
@@ -175,12 +177,23 @@ fn agent_panel_entries_with_runtimes(
state: detail.state,
seen: detail.seen,
custom_status: detail.custom_status,
+ state_labels: detail.state_labels,
})
})
.collect(),
}
}
+pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str {
+ match (state, seen) {
+ (AgentState::Idle, false) => "done",
+ (AgentState::Idle, true) => "idle",
+ (AgentState::Working, _) => "working",
+ (AgentState::Blocked, _) => "blocked",
+ (AgentState::Unknown, _) => "unknown",
+ }
+}
+
fn truncate_text(text: &str, max_width: usize) -> String {
let len = text.chars().count();
if len <= max_width {
@@ -1090,7 +1103,11 @@ fn render_agent_detail(
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 = state_label(detail.state, detail.seen);
+ 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)
@@ -1334,6 +1351,7 @@ mod tests {
state: AgentState::Idle,
seen: true,
custom_status: None,
+ state_labels: std::collections::HashMap::new(),
};
let label = format_agent_panel_primary_label(&entry, 18);
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index 4024f081..c9c1266d 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -18,6 +18,7 @@ pub struct PaneDetail {
pub state: AgentState,
pub seen: bool,
pub custom_status: Option<String>,
+ pub state_labels: HashMap<String, String>,
}
impl Tab {
@@ -36,11 +37,15 @@ impl Tab {
.filter_map(|id| {
let pane = self.panes.get(id)?;
let terminal = terminals.get(&pane.attached_terminal_id)?;
- let agent_label = terminal
+ let fallback_agent_label = terminal
.agent_name
.as_deref()
.or_else(|| terminal.effective_agent_label())?
.to_string();
+ let agent_label = terminal
+ .effective_display_agent()
+ .unwrap_or_else(|| fallback_agent_label.clone());
+ let presentation = terminal.effective_presentation();
Some(PaneDetail {
pane_id: *id,
tab_idx: self.number.saturating_sub(1),
@@ -50,7 +55,8 @@ impl Tab {
agent: terminal.effective_known_agent(),
state: terminal.state,
seen: pane.seen,
- custom_status: terminal.effective_custom_status().map(str::to_string),
+ custom_status: presentation.custom_status,
+ state_labels: presentation.state_labels,
})
})
.collect()