diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fae20fe695..6a46013162 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -117,16 +117,17 @@ use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; - enum ThreadInteractiveRequest { Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), } +const TITLE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// Baseline cadence for periodic stream commit animation ticks. /// /// Smooth-mode streaming drains one line per tick, so this interval controls /// perceived typing speed for non-backlogged output. const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL; +const TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -729,12 +730,47 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } -fn normalize_title_context(title_override: Option) -> Option { - title_override +fn compute_title_context( + title_override: Option, + task_running: bool, + tick: u128, +) -> Option { + let context = title_override .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) - .map(ToString::to_string) + .map(ToString::to_string); + + if !task_running { + return context; + } + + let frame = TITLE_SPINNER_FRAMES[tick as usize % TITLE_SPINNER_FRAMES.len()]; + context + .map(|context| format!("{frame} - {context}")) + .or_else(|| Some(frame.to_string())) +} + +fn title_animation_tick() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + / TITLE_SPINNER_INTERVAL.as_millis() +} + +fn update_terminal_title( + tui: &mut tui::Tui, + title_override: Option, + task_running: bool, +) -> Result<()> { + let title_context = compute_title_context(title_override, task_running, title_animation_tick()); + tui.set_title_context(title_context.as_deref())?; + if task_running { + tui.frame_requester() + .schedule_frame_in(TITLE_SPINNER_INTERVAL); + } + Ok(()) } impl App { @@ -1924,8 +1960,8 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), }; - let title_context = normalize_title_context(app.chat_widget.title_override()); - tui.set_title_context(title_context.as_deref())?; + let task_running = app.chat_widget.is_task_running(); + update_terminal_title(tui, app.chat_widget.title_override(), task_running)?; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] @@ -2025,8 +2061,8 @@ impl App { AppRunControl::Continue } }; - let title_context = normalize_title_context(app.chat_widget.title_override()); - tui.set_title_context(title_context.as_deref())?; + let task_running = app.chat_widget.is_task_running(); + update_terminal_title(tui, app.chat_widget.title_override(), task_running)?; if App::should_stop_waiting_for_initial_session( waiting_for_initial_session_configured, app.primary_thread_id, @@ -3900,10 +3936,28 @@ mod tests { } #[test] - fn normalize_title_context_uses_manual_title_when_present() { + fn title_context_adds_spinner_while_running() { assert_eq!( - normalize_title_context(Some("Named thread".to_string())), - Some("Named thread".to_string()) + compute_title_context(Some("Named thread".to_string()), true, 0), + Some("⠋ - Named thread".to_string()) + ); + assert_eq!( + compute_title_context(Some("Named thread".to_string()), true, 9), + Some("⠏ - Named thread".to_string()) + ); + assert_eq!(compute_title_context(None, true, 0), Some("⠋".to_string())); + } + + #[test] + fn title_context_defaults_to_none_without_manual_title() { + assert_eq!(compute_title_context(None, false, 0), None); + } + + #[test] + fn title_context_prefers_manual_title_when_idle() { + assert_eq!( + compute_title_context(Some("manual title".to_string()), false, 0), + Some("manual title".to_string()) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1dc9689806..692bab1aae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -8545,6 +8545,10 @@ impl ChatWidget { self.title_override = title; } + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + /// Returns the current thread's precomputed rollout path. /// /// For fresh non-ephemeral threads this path may exist before the file is diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 190c967a96..2a95faa2f3 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -61,6 +61,13 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME pub type Terminal = CustomTerminal>; const DEFAULT_TERMINAL_TITLE: &str = "Codex"; +fn has_spinner_prefix(context: &str) -> bool { + matches!( + context.chars().next(), + Some('⠋' | '⠙' | '⠹' | '⠸' | '⠼' | '⠴' | '⠦' | '⠧' | '⠇' | '⠏') + ) +} + fn format_terminal_title(context: Option<&str>) -> String { let context = context .map(|text| { @@ -73,6 +80,9 @@ fn format_terminal_title(context: Option<&str>) -> String { .filter(|text| !text.is_empty()); match context { + Some(context) if has_spinner_prefix(&context) => { + format!("{DEFAULT_TERMINAL_TITLE} {context}") + } Some(context) => format!("{DEFAULT_TERMINAL_TITLE} - {context}"), None => DEFAULT_TERMINAL_TITLE.to_string(), } @@ -608,6 +618,15 @@ mod tests { ); } + #[test] + fn terminal_title_places_spinner_after_codex() { + assert_eq!(format_terminal_title(Some("⠋")), "Codex ⠋"); + assert_eq!( + format_terminal_title(Some("⠋ - fix title syncing")), + "Codex ⠋ - fix title syncing" + ); + } + #[test] fn terminal_title_strips_control_characters() { assert_eq!(