diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-13 03:26:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-13 03:30:13 +0300 |
| commit | 5cfe5e5e730246948b0c9428ad876b6938590aad (patch) | |
| tree | 53d4bbe1aff6887f63f279eed1b5e4e11e7331b7 /src/terminal | |
| parent | 7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (diff) | |
feat: add configurable sidebar metadata tokens
Diffstat (limited to 'src/terminal')
| -rw-r--r-- | src/terminal/metadata.rs | 439 | ||||
| -rw-r--r-- | src/terminal/mod.rs | 2 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 4 | ||||
| -rw-r--r-- | src/terminal/state.rs | 229 | ||||
| -rw-r--r-- | src/terminal/title.rs | 64 |
5 files changed, 247 insertions, 491 deletions
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 2666b32f..225d609a 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -12,12 +12,10 @@ pub struct AgentMetadata { pub applies_to_source: Option<String>, pub title: Option<String>, pub display_agent: Option<String>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, pub reported_at: Instant, title_reported_at: Option<Instant>, display_agent_reported_at: Option<Instant>, - custom_status_reported_at: Option<Instant>, state_label_reported_at: HashMap<String, Instant>, pub ttl: Option<Duration>, expiry_event_pending: bool, @@ -30,11 +28,9 @@ pub struct AgentMetadataReport { pub applies_to_source: Option<String>, pub title: Option<String>, pub display_agent: Option<String>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, pub clear_title: bool, pub clear_display_agent: bool, - pub clear_custom_status: bool, pub clear_state_labels: bool, pub ttl: Option<Duration>, pub seq: Option<u64>, @@ -44,7 +40,6 @@ pub struct AgentMetadataReport { pub struct EffectivePresentation { pub title: Option<String>, pub display_agent: Option<String>, - pub custom_status: Option<String>, pub state_labels: HashMap<String, String>, } @@ -53,36 +48,67 @@ impl EffectivePresentation { Self { title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), } } } impl TerminalState { - fn accept_metadata_report(&mut self, source: &str, seq: Option<u64>) -> bool { + pub(crate) fn metadata_report_sequence_is_fresh(&self, source: &str, seq: Option<u64>) -> bool { + crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq) + } + + pub(crate) fn accept_metadata_report( + &mut self, + source: &str, + seq: Option<u64>, + includes_tokens: bool, + ) -> Result<bool, ()> { let Some(seq) = seq else { - return true; + return Ok(true); }; - - if self - .metadata_report_sequences - .get(source) - .is_some_and(|last_seq| seq <= *last_seq) + if !self.metadata_report_sequence_is_fresh(source, Some(seq)) { + return Ok(false); + } + if includes_tokens + && !self.metadata_token_sequence_sources.contains(source) + && self.metadata_token_sequence_sources.len() + >= crate::metadata_tokens::MAX_SEQUENCE_SOURCES { - return false; + return Err(()); } - self.metadata_report_sequences .insert(source.to_string(), seq); - true + if includes_tokens { + self.metadata_token_sequence_sources + .insert(source.to_string()); + } + Ok(true) + } + + fn metadata_guards_match( + &self, + agent_label: Option<&str>, + applies_to_source: Option<&str>, + ) -> bool { + if agent_label.is_some_and(|agent| self.effective_agent_label() != Some(agent)) { + return false; + } + !applies_to_source.is_some_and(|source| { + self.hook_authority + .as_ref() + .is_none_or(|authority| authority.source != source) + }) } pub fn set_agent_metadata( &mut self, report: AgentMetadataReport, ) -> Option<TerminalStateMutation> { - if !self.accept_metadata_report(&report.source, report.seq) { + if !matches!( + self.accept_metadata_report(&report.source, report.seq, false), + Ok(true) + ) { return None; } @@ -100,17 +126,12 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let has_set_fields = report.title.is_some() || report.display_agent.is_some() - || report.custom_status.is_some() || !report.state_labels.is_empty(); let report_source = report.source.clone(); let report_has_ttl = report.ttl.is_some(); - if report.clear_title - || report.clear_display_agent - || report.clear_custom_status - || report.clear_state_labels - { + if report.clear_title || report.clear_display_agent || report.clear_state_labels { let metadata = self .agent_metadata .entry(report.source.clone()) @@ -120,12 +141,10 @@ impl TerminalState { applies_to_source: report.applies_to_source.clone(), title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), reported_at: now, title_reported_at: None, display_agent_reported_at: None, - custom_status_reported_at: None, state_label_reported_at: HashMap::new(), ttl: report.ttl, expiry_event_pending: false, @@ -138,10 +157,6 @@ impl TerminalState { metadata.display_agent = None; metadata.display_agent_reported_at = None; } - if report.clear_custom_status { - metadata.custom_status = None; - metadata.custom_status_reported_at = None; - } if report.clear_state_labels { metadata.state_labels.clear(); metadata.state_label_reported_at.clear(); @@ -160,10 +175,6 @@ impl TerminalState { metadata.display_agent = Some(display_agent); metadata.display_agent_reported_at = Some(now); } - if let Some(custom_status) = report.custom_status { - metadata.custom_status = Some(custom_status); - metadata.custom_status_reported_at = Some(now); - } for (state, label) in report.state_labels { metadata.state_labels.insert(state.clone(), label); metadata.state_label_reported_at.insert(state, now); @@ -176,7 +187,6 @@ impl TerminalState { } else { let title_reported_at = report.title.as_ref().map(|_| now); let display_agent_reported_at = report.display_agent.as_ref().map(|_| now); - let custom_status_reported_at = report.custom_status.as_ref().map(|_| now); let state_label_reported_at = report .state_labels .keys() @@ -190,12 +200,10 @@ impl TerminalState { applies_to_source: report.applies_to_source, title: report.title, display_agent: report.display_agent, - custom_status: report.custom_status, state_labels: report.state_labels, reported_at: now, title_reported_at, display_agent_reported_at, - custom_status_reported_at, state_label_reported_at, ttl: report.ttl, expiry_event_pending: false, @@ -231,11 +239,6 @@ impl TerminalState { session_ref_changed: false, }) } - pub fn effective_custom_status(&self) -> Option<String> { - self.effective_presentation_for_state_at(self.state, Instant::now()) - .custom_status - } - pub fn effective_title(&self) -> Option<String> { self.effective_presentation_for_state_at(self.state, Instant::now()) .title @@ -337,7 +340,7 @@ impl TerminalState { fn effective_presentation_for_state_at_with_ttl( &self, - state: AgentState, + _state: AgentState, now: Instant, enforce_ttl: bool, ) -> EffectivePresentation { @@ -345,30 +348,9 @@ impl TerminalState { presentation.title = self.newest_metadata_title(now, enforce_ttl); presentation.display_agent = self.newest_metadata_display_agent(now, enforce_ttl); presentation.state_labels = self.effective_metadata_state_labels(now, enforce_ttl); - presentation.custom_status = - self.effective_custom_status_for_state_at_with_ttl(state, now, enforce_ttl); presentation } - fn effective_custom_status_for_state_at_with_ttl( - &self, - _state: AgentState, - now: Instant, - enforce_ttl: bool, - ) -> Option<String> { - if let Some(custom_status) = self.newest_metadata_custom_status(now, enforce_ttl) { - return Some(custom_status); - } - - if self.visible_blocker_overrides_hook() { - return None; - } - - self.hook_authority - .as_ref() - .and_then(|authority| authority.custom_status.clone()) - } - fn valid_agent_metadata( &self, now: Instant, @@ -393,13 +375,6 @@ impl TerminalState { .and_then(|metadata| metadata.display_agent.clone()) } - fn newest_metadata_custom_status(&self, now: Instant, enforce_ttl: bool) -> Option<String> { - self.valid_agent_metadata(now, enforce_ttl) - .filter(|metadata| metadata.custom_status.is_some()) - .max_by_key(|metadata| metadata.custom_status_reported_at) - .and_then(|metadata| metadata.custom_status.clone()) - } - fn effective_metadata_state_labels( &self, now: Instant, @@ -432,7 +407,6 @@ impl TerminalState { ) -> bool { if metadata.title.is_none() && metadata.display_agent.is_none() - && metadata.custom_status.is_none() && metadata.state_labels.is_empty() { return false; @@ -446,7 +420,6 @@ impl TerminalState { fn agent_metadata_is_visible_ignoring_ttl(&self, metadata: &AgentMetadata) -> bool { (metadata.title.is_some() || metadata.display_agent.is_some() - || metadata.custom_status.is_some() || !metadata.state_labels.is_empty()) && self.agent_metadata_matches_guards(metadata) } @@ -483,21 +456,10 @@ impl TerminalState { } fn agent_metadata_matches_guards(&self, metadata: &AgentMetadata) -> bool { - if metadata - .agent_label - .as_deref() - .is_some_and(|agent| self.effective_agent_label() != Some(agent)) - { - return false; - } - if metadata.applies_to_source.as_deref().is_some_and(|source| { - self.hook_authority - .as_ref() - .is_none_or(|authority| authority.source != source) - }) { - return false; - } - true + self.metadata_guards_match( + metadata.agent_label.as_deref(), + metadata.applies_to_source.as_deref(), + ) } } @@ -513,129 +475,39 @@ mod tests { TerminalState::new(TerminalId::alloc(), "/tmp".into()) } - fn set_metadata_custom_status( - terminal: &mut TerminalState, - source: &str, - agent_label: Option<&str>, - applies_to_source: Option<&str>, - custom_status: Option<&str>, - clear_custom_status: bool, - ) -> Option<TerminalStateMutation> { - terminal.set_agent_metadata(AgentMetadataReport { - source: source.into(), - agent_label: agent_label.map(str::to_string), - applies_to_source: applies_to_source.map(str::to_string), - title: None, - display_agent: None, - custom_status: custom_status.map(str::to_string), - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_custom_status, - clear_state_labels: false, - ttl: None, - seq: None, - }) - } - - #[test] - fn user_agent_metadata_overrides_hook_custom_status_without_changing_state() { - let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status( - "herdr:claude".into(), - "claude".into(), - AgentState::Working, - None, - Some("thinking".into()), - None, - ); - - let mutation = set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("refactor auth"), - false, - ); - - assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("refactor auth") - ); - let change = mutation.unwrap().effective_state_change.unwrap(); - assert_eq!(change.previous_state, AgentState::Working); - assert_eq!(change.state, AgentState::Working); - assert_eq!( - change.previous_presentation.custom_status.as_deref(), - Some("thinking") - ); - assert_eq!( - change.presentation.custom_status.as_deref(), - Some("refactor auth") - ); - } - - #[test] - fn user_agent_metadata_requires_matching_lifecycle_source() { - let mut terminal = test_terminal(); - terminal.set_hook_authority( - "herdr:codex".into(), - "codex".into(), - AgentState::Working, - None, - None, - ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("refactor auth"), - false, - ); - - assert_eq!(terminal.effective_custom_status(), None); - } - #[test] - fn clearing_user_agent_metadata_restores_hook_custom_status() { + fn presentation_sequences_remain_unbounded_while_token_sequences_are_bounded() { let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status( - "herdr:claude".into(), - "claude".into(), - AgentState::Working, - None, - Some("thinking".into()), - None, - ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("refactor auth"), - false, - ); - - set_metadata_custom_status(&mut terminal, "user:claude-title", None, None, None, true); - + for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES { + assert_eq!( + terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false), + Ok(true) + ); + } + for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES { + assert_eq!( + terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true), + Ok(true) + ); + } assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("thinking") + terminal.accept_metadata_report( + &format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES), + Some(2), + true, + ), + Err(()) ); } #[test] fn user_agent_metadata_overrides_presentation_fields_only() { let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status( + terminal.set_hook_authority( "herdr:claude".into(), "claude".into(), AgentState::Working, None, - Some("thinking".into()), None, ); @@ -645,11 +517,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Refactor auth".into()), display_agent: Some("Claude: auth".into()), - custom_status: Some("middleware".into()), state_labels: HashMap::from([("working".into(), "deep in the mines".into())]), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: None, @@ -660,7 +530,6 @@ mod tests { let presentation = terminal.effective_presentation(); assert_eq!(presentation.title.as_deref(), Some("Refactor auth")); assert_eq!(presentation.display_agent.as_deref(), Some("Claude: auth")); - assert_eq!(presentation.custom_status.as_deref(), Some("middleware")); assert_eq!( presentation.state_labels.get("working").map(String::as_str), Some("deep in the mines") @@ -679,11 +548,9 @@ mod tests { applies_to_source: None, title: Some("Prompt title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: None, @@ -707,27 +574,23 @@ mod tests { None, ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("first"), - false, - ); - set_metadata_custom_status( - &mut terminal, - "user:claude-title", - Some("claude"), - Some("herdr:claude"), - Some("second"), - false, - ); + for title in ["first", "second"] { + terminal.set_agent_metadata(AgentMetadataReport { + source: "user:claude-title".into(), + agent_label: Some("claude".into()), + applies_to_source: Some("herdr:claude".into()), + title: Some(title.into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + ttl: None, + seq: None, + }); + } - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("second") - ); + assert_eq!(terminal.effective_title().as_deref(), Some("second")); } #[test] @@ -746,11 +609,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Prompt title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(1), @@ -760,12 +621,10 @@ mod tests { agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), title: None, - display_agent: None, - custom_status: Some("activity".into()), + display_agent: Some("Claude activity".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(1), @@ -773,7 +632,10 @@ mod tests { let presentation = terminal.effective_presentation(); assert_eq!(presentation.title.as_deref(), Some("Prompt title")); - assert_eq!(presentation.custom_status.as_deref(), Some("activity")); + assert_eq!( + presentation.display_agent.as_deref(), + Some("Claude activity") + ); } #[test] @@ -792,26 +654,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: None, display_agent: Some("First display".into()), - custom_status: Some("old".into()), - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_custom_status: false, - clear_state_labels: false, - ttl: None, - seq: Some(1), - }); - terminal.set_agent_metadata(AgentMetadataReport { - source: "user:second".into(), - agent_label: Some("claude".into()), - applies_to_source: Some("herdr:claude".into()), - title: None, - display_agent: None, - custom_status: Some("new".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(1), @@ -822,11 +667,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Fresh title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: true, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: Some(2), @@ -835,40 +678,6 @@ mod tests { let presentation = terminal.effective_presentation(); assert_eq!(presentation.title.as_deref(), Some("Fresh title")); assert_eq!(presentation.display_agent, None); - assert_eq!(presentation.custom_status.as_deref(), Some("new")); - } - - #[test] - fn metadata_can_set_other_fields_while_clearing_missing_source() { - let mut terminal = test_terminal(); - terminal.set_hook_authority( - "herdr:claude".into(), - "claude".into(), - AgentState::Working, - None, - None, - ); - - terminal.set_agent_metadata(AgentMetadataReport { - source: "user:status".into(), - agent_label: Some("claude".into()), - applies_to_source: Some("herdr:claude".into()), - title: None, - display_agent: None, - custom_status: Some("activity".into()), - state_labels: HashMap::new(), - clear_title: true, - clear_display_agent: false, - clear_custom_status: false, - clear_state_labels: false, - ttl: None, - seq: None, - }); - - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("activity") - ); } #[test] @@ -887,11 +696,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Old title".into()), display_agent: None, - custom_status: Some("old".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -902,27 +709,25 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Fresh title".into()), display_agent: None, - custom_status: Some("fresh".into()), state_labels: HashMap::new(), - clear_title: true, + clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: None, seq: None, }); assert_eq!(terminal.next_agent_metadata_expiry(), None); - assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh")); + assert_eq!(terminal.effective_title().as_deref(), Some("Fresh title")); assert!(terminal .expire_agent_metadata_at( old_deadline + Duration::from_millis(1), old_deadline + Duration::from_millis(1) ) .is_none()); - assert_eq!(terminal.effective_custom_status().as_deref(), Some("fresh")); + assert_eq!(terminal.effective_title().as_deref(), Some("Fresh title")); } #[test] @@ -940,12 +745,10 @@ mod tests { agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), title: Some("Prompt title".into()), - display_agent: None, - custom_status: Some("old".into()), + display_agent: Some("Old display".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -958,11 +761,9 @@ mod tests { applies_to_source: None, title: None, display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, - clear_display_agent: false, - clear_custom_status: true, + clear_display_agent: true, clear_state_labels: false, ttl: None, seq: None, @@ -973,7 +774,7 @@ mod tests { terminal.effective_presentation().title.as_deref(), Some("Prompt title") ); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_presentation().display_agent, None); let mutation = terminal .expire_agent_metadata_at(old_deadline, old_deadline) @@ -996,13 +797,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Activity".into()), display_agent: None, - custom_status: Some("activity".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -1017,11 +816,11 @@ mod tests { assert_eq!(change.previous_state, AgentState::Working); assert_eq!(change.state, AgentState::Working); assert_eq!( - change.previous_presentation.custom_status.as_deref(), - Some("activity") + change.previous_presentation.title.as_deref(), + Some("Activity") ); - assert_eq!(change.presentation.custom_status, None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(change.presentation.title, None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1038,13 +837,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Stale".into()), display_agent: None, - custom_status: Some("stale".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -1055,7 +852,7 @@ mod tests { .and_then(|metadata| terminal.agent_metadata_expiry(metadata)) .unwrap(); assert_eq!(terminal.next_agent_metadata_expiry(), None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_title(), None); terminal.set_hook_authority( "herdr:claude".into(), @@ -1071,7 +868,7 @@ mod tests { assert!(mutation.is_none()); assert!(!terminal.agent_metadata.contains_key("user:status")); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1090,11 +887,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("First".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(1)), seq: None, @@ -1106,11 +901,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: None, display_agent: Some("Second".into()), - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::from_millis(2)), seq: None, @@ -1149,13 +942,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Instant".into()), display_agent: None, - custom_status: Some("instant".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::ZERO), seq: None, @@ -1170,11 +961,11 @@ mod tests { let change = mutation.effective_state_change.unwrap(); assert_eq!( - change.previous_presentation.custom_status.as_deref(), - Some("instant") + change.previous_presentation.title.as_deref(), + Some("Instant") ); - assert_eq!(change.presentation.custom_status, None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(change.presentation.title, None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1191,13 +982,11 @@ mod tests { source: "user:status".into(), agent_label: Some("claude".into()), applies_to_source: Some("herdr:claude".into()), - title: None, + title: Some("Instant".into()), display_agent: None, - custom_status: Some("instant".into()), state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::ZERO), seq: None, @@ -1220,7 +1009,7 @@ mod tests { ); assert_eq!(terminal.next_agent_metadata_expiry(), None); - assert_eq!(terminal.effective_custom_status(), None); + assert_eq!(terminal.effective_title(), None); } #[test] @@ -1239,11 +1028,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: Some("Expired title".into()), display_agent: None, - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, ttl: Some(Duration::ZERO), seq: None, @@ -1256,11 +1043,9 @@ mod tests { applies_to_source: Some("herdr:claude".into()), title: None, display_agent: Some("Fresh display".into()), - custom_status: None, state_labels: HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: true, clear_state_labels: false, ttl: None, seq: None, diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 03432a56..a67b7bae 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -2,6 +2,7 @@ mod id; mod runtime; mod runtime_registry; pub mod state; +mod title; pub use id::TerminalId; pub use runtime::TerminalRuntime; @@ -10,3 +11,4 @@ pub use state::{ AgentMetadataReport, EffectivePresentation, EffectiveStateChange, TerminalState, TerminalStateMutation, }; +pub(crate) use title::stripped_terminal_title; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 1ff799b8..29d19140 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -307,6 +307,10 @@ impl TerminalRuntime { self.0.detection_text() } + pub fn terminal_title(&self) -> Option<String> { + self.0.terminal_title() + } + pub fn agent_osc_title(&self) -> String { self.0.agent_osc_title() } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 2b17404a..632558fd 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -22,7 +22,6 @@ pub struct HookAuthority { pub agent_label: String, pub state: AgentState, pub message: Option<String>, - pub custom_status: Option<String>, pub reported_at: Instant, pub session_ref: Option<crate::agent_resume::AgentSessionRef>, } @@ -59,6 +58,12 @@ pub struct EffectiveStateChange { pub presentation: EffectivePresentation, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct TerminalTitleChange { + pub(crate) raw_changed: bool, + pub(crate) stripped_changed: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TerminalStateMutation { pub effective_state_change: Option<EffectiveStateChange>, @@ -79,13 +84,16 @@ pub struct TerminalState { fallback_observed_at: Option<Instant>, pub hook_authority: Option<HookAuthority>, pub agent_metadata: HashMap<String, AgentMetadata>, + pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, + pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>, metadata_report_sequences: HashMap<String, u64>, + metadata_token_sequence_sources: std::collections::HashSet<String>, pub state: AgentState, pub last_agent_state_change_seq: Option<u64>, pub revision: u64, @@ -106,13 +114,16 @@ impl TerminalState { fallback_observed_at: None, hook_authority: None, agent_metadata: HashMap::new(), + metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, + terminal_title: None, manual_label: None, agent_name: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), metadata_report_sequences: HashMap::new(), + metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, revision: 0, @@ -123,6 +134,28 @@ impl TerminalState { } } + pub(crate) fn terminal_title_stripped(&self) -> Option<String> { + self.terminal_title + .as_deref() + .and_then(super::stripped_terminal_title) + } + + pub(crate) fn set_terminal_title(&mut self, title: Option<String>) -> TerminalTitleChange { + if self.terminal_title == title { + return TerminalTitleChange::default(); + } + let previous_stripped = self.terminal_title_stripped(); + self.terminal_title = title; + let stripped_changed = previous_stripped != self.terminal_title_stripped(); + if stripped_changed { + self.revision = self.revision.wrapping_add(1); + } + TerminalTitleChange { + raw_changed: true, + stripped_changed, + } + } + pub fn with_launch_argv(mut self, argv: Vec<String>) -> Self { self.launch_argv = Some(argv); self @@ -331,25 +364,11 @@ impl TerminalState { message: Option<String>, seq: Option<u64>, ) -> Option<EffectiveStateChange> { - self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) - } - - #[cfg(test)] - pub fn set_hook_authority_with_custom_status( - &mut self, - source: String, - agent_label: String, - state: AgentState, - message: Option<String>, - custom_status: Option<String>, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - self.set_hook_authority_with_custom_status_at( + self.set_hook_authority_at( source, agent_label, state, message, - custom_status, None, seq, Instant::now(), @@ -363,29 +382,26 @@ impl TerminalState { agent_label: String, state: AgentState, message: Option<String>, - custom_status: Option<String>, session_ref: Option<crate::agent_resume::AgentSessionRef>, seq: Option<u64>, ) -> Option<TerminalStateMutation> { - self.set_hook_authority_with_custom_status_at( + self.set_hook_authority_at( source, agent_label, state, message, - custom_status, session_ref, seq, Instant::now(), ) } - pub fn set_hook_authority_with_custom_status_at( + pub fn set_hook_authority_at( &mut self, source: String, agent_label: String, state: AgentState, message: Option<String>, - custom_status: Option<String>, session_ref: Option<crate::agent_resume::AgentSessionRef>, seq: Option<u64>, now: Instant, @@ -476,7 +492,6 @@ impl TerminalState { agent_label, state, message, - custom_status, reported_at: now, session_ref, }); @@ -1524,7 +1539,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(10), ); @@ -1548,7 +1562,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(12), ); @@ -1576,7 +1589,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(10), ); @@ -1593,7 +1605,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(session_b.clone()), Some(12), ); @@ -1610,7 +1621,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(14), ); @@ -1628,7 +1638,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(session_b), Some(15), ); @@ -1681,7 +1690,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(10), ); @@ -1698,7 +1706,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session), Some(12), ); @@ -1725,7 +1732,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(10), ); @@ -1754,7 +1760,6 @@ mod tests { "omp".into(), AgentState::Blocked, Some("waiting".into()), - None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(12), ); @@ -1771,7 +1776,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(13), ); @@ -1785,13 +1789,12 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(10), now, ); @@ -1813,13 +1816,12 @@ mod tests { AgentState::Working ); - let stale = terminal.set_hook_authority_with_custom_status_at( + let stale = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(9), now + Duration::from_millis(2), ); @@ -1833,13 +1835,12 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(20), now, ); @@ -1853,12 +1854,11 @@ mod tests { true, now + Duration::from_millis(1), ); - let late = terminal.set_hook_authority_with_custom_status_at( + let late = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - Some("late".into()), None, Some(21), now + Duration::from_millis(2), @@ -1881,7 +1881,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); @@ -1900,7 +1899,6 @@ mod tests { "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_path), Some(21), ); @@ -1946,7 +1944,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); @@ -1957,7 +1954,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(22), ); @@ -1976,7 +1972,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), Some(20), ); @@ -1987,7 +1982,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), Some(22), ); @@ -2006,7 +2000,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), Some(1000), ); @@ -2017,7 +2010,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), Some(1500), ); @@ -2039,7 +2031,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(1000), ); @@ -2050,7 +2041,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_b), Some(1500), ); @@ -2062,7 +2052,6 @@ mod tests { "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_a), Some(2500), ); @@ -2071,7 +2060,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_c), Some(2500), ); @@ -2091,7 +2079,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")), Some(20), ); @@ -2101,7 +2088,6 @@ mod tests { "pi".into(), AgentState::Idle, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")), Some(21), ); @@ -2219,7 +2205,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session.clone()), Some(1000), ); @@ -2259,7 +2244,6 @@ mod tests { "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(old_session), Some(500), ); @@ -2268,7 +2252,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session), Some(500), ); @@ -2286,12 +2269,11 @@ mod tests { let new_session = test_session_path("new-after-process-exit.jsonl"); let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(1000), now, @@ -2306,12 +2288,11 @@ mod tests { now + Duration::from_millis(1), ); - let early_new = terminal.set_hook_authority_with_custom_status_at( + let early_new = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session.clone()), Some(500), now + Duration::from_millis(2), @@ -2338,12 +2319,11 @@ mod tests { false, now + Duration::from_millis(4), ); - let fresh_new = terminal.set_hook_authority_with_custom_status_at( + let fresh_new = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session), Some(500), now + Duration::from_millis(5), @@ -2360,12 +2340,11 @@ mod tests { let old_session = test_session_path("old-before-nosession-process-exit.jsonl"); let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(old_session), Some(1000), now, @@ -2380,13 +2359,12 @@ mod tests { now + Duration::from_millis(1), ); - let early_without_session = terminal.set_hook_authority_with_custom_status_at( + let early_without_session = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(500), now + Duration::from_millis(2), ); @@ -2412,13 +2390,12 @@ mod tests { false, now + Duration::from_millis(4), ); - let fresh_without_session = terminal.set_hook_authority_with_custom_status_at( + let fresh_without_session = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, - None, Some(500), now + Duration::from_millis(5), ); @@ -2436,12 +2413,11 @@ mod tests { let session_c = test_session_path("generation-c.jsonl"); let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_a.clone()), Some(1000), now, @@ -2474,12 +2450,11 @@ mod tests { false, now + Duration::from_millis(3), ); - let generation_b = terminal.set_hook_authority_with_custom_status_at( + let generation_b = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_b), Some(500), now + Duration::from_millis(4), @@ -2514,22 +2489,20 @@ mod tests { now + Duration::from_millis(7), ); - let late_generation_a = terminal.set_hook_authority_with_custom_status_at( + let late_generation_a = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - Some("late".into()), crate::agent_resume::AgentSessionRef::path(session_a), Some(250), now + Duration::from_millis(8), ); - let generation_c = terminal.set_hook_authority_with_custom_status_at( + let generation_c = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_c), Some(250), now + Duration::from_millis(9), @@ -2596,7 +2569,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("fresh-session"), Some(22), ); @@ -2638,7 +2610,6 @@ mod tests { "mastracode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("mastracode-new"), Some(20), ) @@ -2669,7 +2640,6 @@ mod tests { agent_label.into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id(&old_session), Some(20), ); @@ -2682,7 +2652,6 @@ mod tests { agent_label.into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id(&old_session), Some(22), ); @@ -2695,7 +2664,6 @@ mod tests { agent_label.into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id(&new_session), Some(23), ); @@ -2709,12 +2677,11 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:omp".into(), "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(1000), now, @@ -2734,7 +2701,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("omp-old"), Some(500), ); @@ -2764,7 +2730,6 @@ mod tests { "omp".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("omp-new"), Some(500), ); @@ -2898,41 +2863,15 @@ mod tests { } #[test] - fn visible_blocker_suppresses_stale_hook_custom_status() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); - terminal.set_hook_authority_with_custom_status( - "herdr:codex".into(), - "codex".into(), - AgentState::Working, - None, - Some("planning".into()), - None, - ); - - terminal.set_detected_state_with_visible_blocker( - Some(Agent::Codex), - AgentState::Blocked, - true, - false, - false, - ); - - assert_eq!(terminal.state, AgentState::Blocked); - assert_eq!(terminal.effective_custom_status(), None); - } - - #[test] fn fallback_idle_does_not_override_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Working, None, - Some("thinking".into()), None, None, now, @@ -2950,10 +2889,6 @@ mod tests { assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("thinking") - ); } #[test] @@ -2961,12 +2896,11 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, - Some("thinking".into()), None, None, now, @@ -2983,10 +2917,6 @@ mod tests { assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("thinking") - ); } #[test] @@ -2994,14 +2924,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Idle, None, None, None, - None, now, ); @@ -3025,14 +2954,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:hermes".into(), "hermes".into(), AgentState::Idle, None, None, None, - None, now, ); @@ -3056,14 +2984,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:kilo".into(), "kilo".into(), AgentState::Idle, None, None, None, - None, now, ); @@ -3096,14 +3023,13 @@ mod tests { now, ); - let change = terminal.set_hook_authority_with_custom_status_at( + let change = terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Idle, None, None, None, - None, now + Duration::from_millis(100), ); @@ -3131,12 +3057,11 @@ mod tests { false, now, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Blocked, None, - Some("permission".into()), None, None, now + Duration::from_millis(1201), @@ -3156,10 +3081,6 @@ mod tests { assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Blocked); - assert_eq!( - terminal.effective_custom_status().as_deref(), - Some("permission") - ); assert!(change.effective_state_change.is_none()); } @@ -3271,14 +3192,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, None, None, - None, now, ); @@ -3379,13 +3299,12 @@ mod tests { false, observed, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:claude".into(), "claude".into(), AgentState::Working, None, None, - None, Some(1), observed + Duration::from_secs(1), ); @@ -3416,22 +3335,20 @@ mod tests { false, observed, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, - None, Some(1), observed, ); - terminal.set_hook_authority_with_custom_status_at( + terminal.set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, - Some("new turn".into()), None, Some(2), observed + Duration::from_secs(1), @@ -3448,7 +3365,7 @@ mod tests { ); let authority = terminal.hook_authority.as_ref().expect("hook authority"); - assert_eq!(authority.custom_status.as_deref(), Some("new turn")); + assert_eq!(authority.reported_at, observed + Duration::from_secs(1)); assert_eq!(terminal.state, AgentState::Working); assert_eq!(terminal.effective_agent_label(), Some("codex")); } @@ -3530,7 +3447,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ) @@ -3560,7 +3476,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(20), ); @@ -3570,7 +3485,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(new_session_path), Some(19), ); @@ -3595,7 +3509,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); @@ -3607,7 +3520,6 @@ mod tests { AgentState::Working, None, None, - None, Some(21), ) .expect("accepted report"); @@ -3638,7 +3550,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), Some(20), ) @@ -4019,12 +3930,11 @@ mod tests { let mut terminal = test_terminal(); let now = std::time::Instant::now(); terminal - .set_hook_authority_with_custom_status_at( + .set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), now + Duration::from_millis(1), @@ -4066,7 +3976,6 @@ mod tests { "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(22), ); @@ -4078,7 +3987,6 @@ mod tests { "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-new-session"), Some(23), ); @@ -4102,7 +4010,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"), Some(21), ); @@ -4152,7 +4059,6 @@ mod tests { "opencode".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), ) @@ -4164,7 +4070,6 @@ mod tests { "opencode".into(), AgentState::Blocked, Some("needs approval".into()), - None, crate::agent_resume::AgentSessionRef::id("nested-session"), Some(21), ) @@ -4224,7 +4129,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); @@ -4246,7 +4150,6 @@ mod tests { "pi".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::path(session_path), Some(20), ); @@ -4419,7 +4322,6 @@ mod tests { "claude".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("claude-session"), Some(20), ); @@ -4448,7 +4350,6 @@ mod tests { "hermes".into(), AgentState::Working, None, - None, crate::agent_resume::AgentSessionRef::id("hermes-session"), Some(20), ); diff --git a/src/terminal/title.rs b/src/terminal/title.rs new file mode 100644 index 00000000..32ba19c4 --- /dev/null +++ b/src/terminal/title.rs @@ -0,0 +1,64 @@ +const CLAUDE_ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽"; + +pub(crate) fn stripped_terminal_title(title: &str) -> Option<String> { + let title = title.trim(); + if title.is_empty() { + return None; + } + + let mut chars = title.char_indices(); + let (_, first) = chars.next()?; + let after_first = &title[first.len_utf8()..]; + let recognized = + matches!(first, '\u{2800}'..='\u{28ff}') || CLAUDE_ACTIVITY_GLYPHS.contains(first); + let stripped = if recognized + && (after_first.is_empty() || after_first.chars().next().is_some_and(char::is_whitespace)) + { + after_first.trim() + } else { + title + }; + + (!stripped.is_empty()).then(|| stripped.to_string()) +} + +#[cfg(test)] +mod tests { + use super::stripped_terminal_title; + + #[test] + fn strips_one_recognized_leading_activity_glyph() { + for title in ["⠋ task", "✳ task", " ⠙ task ", "✢ task", "✻ task"] { + assert_eq!(stripped_terminal_title(title).as_deref(), Some("task")); + } + assert_eq!( + stripped_terminal_title("⠋ ⠙ task").as_deref(), + Some("⠙ task") + ); + } + + #[test] + fn preserves_unrecognized_or_unbounded_symbols() { + for (title, expected) in [ + ("★task", "★task"), + ("★ production", "★ production"), + ("✨ task", "✨ task"), + ("☼ status", "☼ status"), + ("@ task", "@ task"), + ("task ⠋ detail", "task ⠋ detail"), + ("[prod] task", "[prod] task"), + ] { + assert_eq!(stripped_terminal_title(title).as_deref(), Some(expected)); + } + } + + #[test] + fn preserves_unicode_text_and_elides_empty_results() { + assert_eq!( + stripped_terminal_title(" ⠋ 修复🙂标题 ").as_deref(), + Some("修复🙂标题") + ); + assert_eq!(stripped_terminal_title(" "), None); + assert_eq!(stripped_terminal_title("⠋ "), None); + } +} |
