diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 17:10:28 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 17:10:28 +0300 |
| commit | 3750e0bae21c54da3947d337584f180a21d0bc67 (patch) | |
| tree | 4542d8af76b4fee0c428ca5e9378a06946ef62ca /src | |
| parent | 1b980eaa9d4cbc050b6b81525e4b6b72ae853be5 (diff) | |
feat: add pane metadata reporting
refs #36
Diffstat (limited to 'src')
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 != ¤t_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(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.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() |
