use std::fmt::Write; use crossterm::terminal::SetTitle; use super::config::{TitleConfig, TitleItem}; use crate::acp::tracker::TurnActivity; const TITLE_SPINNER: &[char] = &[ '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}', ]; /// Hold each spinner frame for this many ticks before advancing. /// /// Terminals (notably Ghostty) debounce tab title updates, so writing a /// new title every tick (~33ms at 30fps) produces more OSC 0 writes than /// the tab bar can render. A divisor of 8 gives ~264ms per frame — slow /// enough for debounced renderers while still looking animated. const TITLE_SPINNER_DIVISOR: u64 = 8; /// Hold the "⚠ Action Required" label for this many ticks before toggling /// (only while unfocused; see focused field below). /// /// A divisor of 15 at 30fps gives ~500ms visible, ~500ms hidden — a calm 1s /// blink cycle that reads as intentional rather than broken flickering. When /// focused we show the prefix statically to eliminate oscillation during /// active interaction (e.g. typing in permission modals). const ACTION_REQUIRED_BLINK_DIVISOR: u64 = 15; /// State passed into `TitleManager::update()` each tick. pub struct TitleState<'a> { pub session_name: Option<&'a str>, pub model: Option<&'a str>, pub activity: Option<&'a TurnActivity>, pub has_pending_permissions: bool, pub cwd: Option<&'a str>, pub turn_elapsed: Option, /// Whether the agent is busy (turn or command running), even if /// `activity` is `None` (the "Waiting" gap before first chunk). pub is_busy: bool, /// Whether the terminal pane/window is currently focused (from /// FocusTracker). Suppresses title blinking/oscillation while the /// user is actively interacting. pub focused: bool, } pub struct TitleManager { items: Vec, last_title: String, composed: String, spinner_frame: usize, tick_count: u64, } impl TitleManager { pub fn new(config: &TitleConfig) -> Self { Self { items: config.items.clone(), last_title: String::new(), composed: String::new(), spinner_frame: 0, tick_count: 0, } } /// Compose the title string from the current state. /// /// Returns the escape sequence bytes to set the terminal title when the /// composed title differs from the last one emitted. Returns `None` when /// the title is unchanged (dedup). pub fn update(&mut self, state: &TitleState<'_>) -> Option { self.composed.clear(); let mut has_parts = false; // Iterate by index: TitleItem is Copy, so indexing avoids borrowing // self.items while we mutate self.composed. for i in 0..self.items.len() { let item = self.items[i]; if write_item( &mut self.composed, &mut has_parts, item, state, self.spinner_frame, self.tick_count, ) { continue; } } if !has_parts { self.composed.clear(); self.composed.push_str("kigi"); } let result = if self.composed != self.last_title { Some(build_title_escape(&self.composed)) } else { None }; // Swap into last_title when changed (update the dedup cache). if result.is_some() { std::mem::swap(&mut self.last_title, &mut self.composed); } // Advance counters after rendering so the first tick sees // tick_count=0 (phase 0, ActionRequired visible) and spinner_frame=0. self.tick_count = self.tick_count.wrapping_add(1); self.spinner_frame = (self.tick_count / TITLE_SPINNER_DIVISOR) as usize % TITLE_SPINNER.len(); result } pub fn reset(&mut self) -> String { let esc = build_title_escape("kigi"); self.last_title.clear(); self.last_title.push_str("kigi"); self.spinner_frame = 0; self.tick_count = 0; esc } } /// Render a single title item into `buf`. Returns `true` if a part was written. fn write_item( buf: &mut String, has_parts: &mut bool, item: TitleItem, state: &TitleState<'_>, spinner_frame: usize, tick_count: u64, ) -> bool { match item { TitleItem::Kigi => { push_separator(buf, has_parts); buf.push_str("kigi"); } TitleItem::Spinner => { if !state.is_busy && state.activity.is_none() { return false; } push_separator(buf, has_parts); buf.push(TITLE_SPINNER[spinner_frame]); } TitleItem::Activity => { if let Some(activity) = state.activity { push_separator(buf, has_parts); write_activity(buf, activity); } else if state.is_busy { push_separator(buf, has_parts); buf.push_str("Waiting"); } else { return false; } } TitleItem::SessionName => { let Some(name) = state.session_name.filter(|s| !s.is_empty()) else { return false; }; push_separator(buf, has_parts); write_truncated(buf, name, 40); } TitleItem::Model => { let Some(model) = state.model.filter(|s| !s.is_empty()) else { return false; }; push_separator(buf, has_parts); write_truncated(buf, model, 30); } TitleItem::Cwd => { let Some(cwd) = state.cwd else { return false; }; let short = cwd.rsplit('/').next().unwrap_or(cwd); if short.is_empty() { return false; } push_separator(buf, has_parts); write_truncated(buf, short, 30); } TitleItem::TurnTimer => { let Some(elapsed) = state.turn_elapsed else { return false; }; let secs = elapsed.as_secs(); if secs < 1 { return false; } push_separator(buf, has_parts); let _ = write!(buf, "{}s", secs); } TitleItem::ActionRequired => { if !state.has_pending_permissions { return false; } // Blink (oscillate) only while unfocused, for tab attention. // When focused (user actively interacting, e.g. in permission // modal or prompt), show static prefix to stop distracting flash. let should_blink = !state.focused && !(tick_count / ACTION_REQUIRED_BLINK_DIVISOR).is_multiple_of(2); if should_blink { return false; } push_separator(buf, has_parts); buf.push_str("\u{26A0} Action Required"); } } *has_parts = true; true } fn push_separator(buf: &mut String, has_parts: &mut bool) { if *has_parts { buf.push_str(" - "); } } fn write_activity(buf: &mut String, activity: &TurnActivity) { match activity { TurnActivity::Thinking => buf.push_str("Thinking"), TurnActivity::Responding => buf.push_str("Responding"), TurnActivity::ToolRunning { title, description } => { if let Some(desc) = description .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { buf.push_str(&crate::acp::tracker::format_waiting_for_subject(desc)); } else if title.is_empty() { buf.push_str("Running tool"); } else { buf.push_str("Running: "); write_truncated(buf, title, 30); } } TurnActivity::AutoCompacting => buf.push_str("Compacting"), TurnActivity::Retrying { attempt, max_retries, .. } => { let _ = write!(buf, "Retrying ({}/{})", attempt, max_retries); } TurnActivity::Waiting(reason) => buf.push_str(&reason.label()), } } fn write_truncated(buf: &mut String, s: &str, max: usize) { // Fast path: ASCII-only strings where byte length == char count. if s.len() <= max { buf.push_str(s); return; } // Slow path: iterate chars for multi-byte or over-limit strings. for (count, ch) in s.chars().enumerate() { if count >= max { buf.push('\u{2026}'); return; } buf.push(ch); } } /// Build the escape sequence for setting the terminal title without writing /// it to stderr. The caller is responsible for routing these bytes through /// the frame pipeline. /// /// Control characters are stripped here: title parts include remote-sourced /// strings (e.g. kimi.com conversation titles), which must not terminate the /// OSC sequence early or inject escapes into the terminal. fn build_title_escape(title: &str) -> String { let sanitized: String = title.chars().filter(|c| !c.is_control()).collect(); let mut buf = Vec::new(); let _ = crossterm::queue!(&mut buf, SetTitle(sanitized)); String::from_utf8(buf).expect("crossterm SetTitle produces valid UTF-8") } #[cfg(test)] mod tests { use super::*; fn default_config() -> TitleConfig { TitleConfig::default() } fn config_with_items(items: Vec) -> TitleConfig { TitleConfig { enabled: true, items, } } fn idle_state<'a>() -> TitleState<'a> { TitleState { session_name: None, model: None, activity: None, has_pending_permissions: false, cwd: None, turn_elapsed: None, is_busy: false, focused: true, } } // --- Title composition tests --- #[test] fn kigi_only_produces_just_kigi() { let cfg = config_with_items(vec![TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = idle_state(); mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); } #[test] fn session_name_and_kigi_joined_with_separator() { let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { session_name: Some("my project"), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "my project - kigi"); } #[test] fn missing_session_name_skipped() { let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = idle_state(); mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); } #[test] fn empty_session_name_skipped() { let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { session_name: Some(""), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); } #[test] fn spinner_only_shown_when_active() { let cfg = config_with_items(vec![TitleItem::Spinner, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); // Idle: spinner absent mgr.update(&idle_state()); assert_eq!(mgr.last_title, "kigi"); // Active: spinner present let activity = TurnActivity::Thinking; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert!(mgr.last_title.contains(" - kigi")); let spinner_part: String = mgr.last_title.chars().take(1).collect(); assert!( TITLE_SPINNER.contains(&spinner_part.chars().next().unwrap()), "expected braille spinner char, got: {}", spinner_part ); } #[test] fn spinner_advances_with_divisor() { let cfg = config_with_items(vec![TitleItem::Spinner]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { activity: Some(&activity), ..idle_state() }; // Run through one full cycle (DIVISOR ticks per frame * frame count). let total = TITLE_SPINNER_DIVISOR as usize * TITLE_SPINNER.len(); let mut frames = Vec::new(); for _ in 0..total { mgr.update(&state); frames.push(mgr.last_title.clone()); } // Across a full cycle we should see all spinner frames. let unique: std::collections::HashSet<_> = frames.iter().collect(); assert_eq!(unique.len(), TITLE_SPINNER.len()); } #[test] fn spinner_holds_frame_for_divisor_ticks() { let cfg = config_with_items(vec![TitleItem::Spinner]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { activity: Some(&activity), ..idle_state() }; // First frame should be stable for DIVISOR ticks. mgr.update(&state); let first = mgr.last_title.clone(); for _ in 1..TITLE_SPINNER_DIVISOR { mgr.update(&state); assert_eq!( mgr.last_title, first, "spinner should hold frame during divisor window" ); } // After DIVISOR ticks, the frame should advance. mgr.update(&state); assert_ne!( mgr.last_title, first, "spinner should advance after divisor ticks" ); } #[test] fn spinner_wraps_around() { let cfg = config_with_items(vec![TitleItem::Spinner]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { activity: Some(&activity), ..idle_state() }; // Run through more than one full cycle. mgr.update(&state); let first = mgr.last_title.clone(); let total = TITLE_SPINNER_DIVISOR as usize * TITLE_SPINNER.len(); for _ in 1..total { mgr.update(&state); } // After a full cycle, the frame should wrap back to the first char. mgr.update(&state); assert_eq!(mgr.last_title, first); } #[test] fn activity_label_thinking() { let cfg = config_with_items(vec![TitleItem::Activity]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Thinking"); } #[test] fn activity_label_responding() { let cfg = config_with_items(vec![TitleItem::Activity]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Responding; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Responding"); } #[test] fn activity_label_tool_running_with_title() { let cfg = config_with_items(vec![TitleItem::Activity]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::ToolRunning { title: "cargo build".to_owned(), description: None, }; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Running: cargo build"); } #[test] fn activity_label_tool_running_empty_title() { let cfg = config_with_items(vec![TitleItem::Activity]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::ToolRunning { title: String::new(), description: None, }; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Running tool"); } #[test] fn activity_label_retrying() { let cfg = config_with_items(vec![TitleItem::Activity]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Retrying { attempt: 2, max_retries: 5, reason: "timeout".to_owned(), }; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Retrying (2/5)"); } #[test] fn activity_hidden_when_idle() { let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); mgr.update(&idle_state()); assert_eq!(mgr.last_title, "kigi"); } #[test] fn spinner_shown_when_busy_without_activity() { let cfg = config_with_items(vec![TitleItem::Spinner, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { is_busy: true, ..idle_state() }; mgr.update(&state); assert!(mgr.last_title.contains(" - kigi")); let spinner_part: String = mgr.last_title.chars().take(1).collect(); assert!( TITLE_SPINNER.contains(&spinner_part.chars().next().unwrap()), "expected braille spinner char during Waiting, got: {}", spinner_part ); } #[test] fn activity_shows_waiting_when_busy_without_activity() { let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { is_busy: true, ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Waiting - kigi"); } #[test] fn activity_prefers_specific_activity_over_waiting() { let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { activity: Some(&activity), is_busy: true, ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "Thinking - kigi"); } // --- Action Required blinking --- #[test] fn action_required_visible_on_first_tick() { let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { has_pending_permissions: true, ..idle_state() }; // tick_count=0 (even) on first render → ActionRequired visible. mgr.update(&state); assert!( mgr.last_title.contains("Action Required"), "first tick should show ActionRequired, got: {}", mgr.last_title ); } #[test] fn action_required_blinks_across_ticks() { let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { has_pending_permissions: true, focused: false, // unfocused → should blink ..idle_state() }; // First tick: tick_count=0, phase=0 (visible). mgr.update(&state); let t1 = mgr.last_title.clone(); // Title stays stable for the rest of the visible phase. for _ in 1..ACTION_REQUIRED_BLINK_DIVISOR { mgr.update(&state); assert_eq!( mgr.last_title, t1, "title should stay stable within a blink phase" ); } // Crossing into the hidden phase. mgr.update(&state); let t2 = mgr.last_title.clone(); assert_ne!(t1, t2); assert!(t1.contains("Action Required")); assert!(!t2.contains("Action Required")); } #[test] fn action_required_hidden_when_no_permissions() { let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { has_pending_permissions: false, ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); } // --- Dedup (no-op when unchanged) --- #[test] fn dedup_skips_emission_when_unchanged() { let cfg = config_with_items(vec![TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = idle_state(); mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); // Second update: title is identical, last_title stays the same (no re-emit). let title_before = mgr.last_title.clone(); mgr.update(&state); assert_eq!(mgr.last_title, title_before); } // --- Empty items list --- #[test] fn empty_items_produces_kigi_fallback() { let cfg = config_with_items(vec![]); let mut mgr = TitleManager::new(&cfg); mgr.update(&idle_state()); assert_eq!(mgr.last_title, "kigi"); } // --- Model item --- #[test] fn model_item_shown_when_present() { let cfg = config_with_items(vec![TitleItem::Model, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { model: Some("kigi-3"), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "kigi-3 - kigi"); } #[test] fn model_item_hidden_when_none() { let cfg = config_with_items(vec![TitleItem::Model, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); mgr.update(&idle_state()); assert_eq!(mgr.last_title, "kigi"); } // --- Cwd item --- #[test] fn cwd_shows_last_component() { let cfg = config_with_items(vec![TitleItem::Cwd, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { cwd: Some("/home/user/my-project"), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "my-project - kigi"); } // --- TurnTimer item --- #[test] fn turn_timer_shown_when_above_one_second() { let cfg = config_with_items(vec![TitleItem::TurnTimer, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { turn_elapsed: Some(std::time::Duration::from_secs(42)), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "42s - kigi"); } #[test] fn turn_timer_hidden_when_under_one_second() { let cfg = config_with_items(vec![TitleItem::TurnTimer, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { turn_elapsed: Some(std::time::Duration::from_millis(500)), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "kigi"); } // --- Truncation --- #[test] fn long_session_name_truncated_with_ellipsis() { let cfg = config_with_items(vec![TitleItem::SessionName]); let mut mgr = TitleManager::new(&cfg); let long_name = "a".repeat(50); let state = TitleState { session_name: Some(&long_name), ..idle_state() }; mgr.update(&state); // 40 chars + ellipsis assert_eq!(mgr.last_title.chars().count(), 41); assert!(mgr.last_title.ends_with('\u{2026}')); } #[test] fn short_session_name_not_truncated() { let cfg = config_with_items(vec![TitleItem::SessionName]); let mut mgr = TitleManager::new(&cfg); let state = TitleState { session_name: Some("short"), ..idle_state() }; mgr.update(&state); assert_eq!(mgr.last_title, "short"); } // --- Reset --- #[test] fn reset_clears_state_and_emits_kigi() { let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Kigi]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { session_name: Some("test"), activity: Some(&activity), ..idle_state() }; mgr.update(&state); assert_ne!(mgr.last_title, "kigi"); mgr.reset(); assert_eq!(mgr.last_title, "kigi"); assert_eq!(mgr.spinner_frame, 0); assert_eq!(mgr.tick_count, 0); } // --- Full default config integration --- #[test] fn default_config_active_turn_with_permissions() { let cfg = default_config(); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Responding; let state = TitleState { session_name: Some("my-session"), activity: Some(&activity), has_pending_permissions: true, focused: false, // unfocused → should blink per original test ..idle_state() }; // First tick: ActionRequired visible. mgr.update(&state); let t1 = mgr.last_title.clone(); // Advance through the blink divisor to reach the hidden phase. for _ in 1..ACTION_REQUIRED_BLINK_DIVISOR { mgr.update(&state); } mgr.update(&state); let t2 = mgr.last_title.clone(); // Both should contain the persistent parts. for t in [&t1, &t2] { assert!(t.contains("kigi"), "title missing 'kigi': {t}"); assert!(t.contains("Responding"), "title missing 'Responding': {t}"); assert!(t.contains("my-session"), "title missing session name: {t}"); } // One should have ActionRequired, the other should not (blinking). let w1 = t1.contains("Action Required"); let w2 = t2.contains("Action Required"); assert_ne!(w1, w2, "expected blink toggle between t1={t1} and t2={t2}"); } #[test] fn default_config_idle_no_session() { let cfg = default_config(); let mut mgr = TitleManager::new(&cfg); mgr.update(&idle_state()); assert_eq!(mgr.last_title, "kigi"); } // --- Multi-item combinations --- #[test] fn all_items_present_in_order() { let cfg = config_with_items(vec![ TitleItem::Activity, TitleItem::SessionName, TitleItem::Model, TitleItem::Cwd, TitleItem::Kigi, ]); let mut mgr = TitleManager::new(&cfg); let activity = TurnActivity::Thinking; let state = TitleState { session_name: Some("proj"), model: Some("kigi-3"), activity: Some(&activity), cwd: Some("/home/user/workspace"), ..idle_state() }; mgr.update(&state); assert_eq!( mgr.last_title, "Thinking - proj - kigi-3 - workspace - kigi" ); } #[test] fn tool_title_truncated_in_activity() { let cfg = config_with_items(vec![TitleItem::Activity]); let mut mgr = TitleManager::new(&cfg); let long_tool = "x".repeat(50); let activity = TurnActivity::ToolRunning { title: long_tool, description: None, }; let state = TitleState { activity: Some(&activity), ..idle_state() }; mgr.update(&state); // "Running: " (9 chars) + 30 chars + ellipsis = 40 chars assert!(mgr.last_title.starts_with("Running: ")); assert!(mgr.last_title.ends_with('\u{2026}')); } /// Remote-sourced title parts must not smuggle control bytes into the /// OSC sequence: the only ESC/BEL in the output is crossterm's framing. #[test] fn title_escape_strips_control_characters() { let esc = build_title_escape("evil\u{1b}]0;pwned\u{7}\r\ntitle"); let inner = esc .strip_prefix("\u{1b}]0;") .and_then(|s| s.strip_suffix('\u{7}')) .expect("crossterm OSC 0 framing"); assert!( !inner.chars().any(char::is_control), "title payload must be control-free: {inner:?}" ); assert_eq!(inner, "evil]0;pwnedtitle"); } }