diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5f909ad211..a488c77ff5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -121,7 +121,7 @@ const TITLE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", " /// 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(32); +const TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -730,7 +730,10 @@ fn decorate_title_context( } let frame = TITLE_SPINNER_FRAMES[tick as usize % TITLE_SPINNER_FRAMES.len()]; - Some(frame.to_string()) + match context { + Some(context) => Some(format!("{frame} - {context}")), + None => Some(frame.to_string()), + } } fn compute_title_context( @@ -3812,12 +3815,13 @@ mod tests { fn decorate_title_context_adds_spinner_while_running() { assert_eq!( decorate_title_context(Some("Working".to_string()), true, 0), - Some("⠋".to_string()) + Some("⠋ - Working".to_string()) ); assert_eq!( decorate_title_context(Some("Working".to_string()), true, 9), - Some("⠏".to_string()) + Some("⠏ - Working".to_string()) ); + assert_eq!(decorate_title_context(None, true, 0), Some("⠋".to_string())); } #[test] diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index eec14dd89e..c8f91b846b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -161,7 +161,7 @@ pub(crate) enum AppEvent { }, InsertHistoryCell(Box), - SetTitle(String), + SetTitle(Option), /// Apply rollback semantics to local transcript cells. /// diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab0daff8eb..365c4318f3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3943,14 +3943,11 @@ impl ChatWidget { .send(AppEvent::CodexOp(Op::SetThreadName { name })); self.bottom_pane.drain_pending_submission_state(); } - SlashCommand::Title if !trimmed.is_empty() => { - let Some(name) = codex_core::util::normalize_thread_name(trimmed) else { - self.add_error_message("Title cannot be empty.".to_string()); - return; - }; - let cell = Self::title_confirmation_cell(&name); + SlashCommand::Title => { + let title = codex_core::util::normalize_thread_name(trimmed); + let cell = Self::title_confirmation_cell(title.as_deref()); self.add_boxed_history(Box::new(cell)); - self.set_title_override(name); + self.set_title_override(title); self.request_redraw(); } SlashCommand::Plan if !trimmed.is_empty() => { @@ -4051,18 +4048,13 @@ impl ChatWidget { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( "Set title".to_string(), - "Type a title and press Enter".to_string(), + "Type a title and press Enter. Leave blank to clear it.".to_string(), None, Box::new(move |name: String| { - let Some(name) = codex_core::util::normalize_thread_name(&name) else { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event("Title cannot be empty.".to_string()), - ))); - return; - }; - let cell = Self::title_confirmation_cell(&name); + let title = codex_core::util::normalize_thread_name(&name); + let cell = Self::title_confirmation_cell(title.as_deref()); tx.send(AppEvent::InsertHistoryCell(Box::new(cell))); - tx.send(AppEvent::SetTitle(name)); + tx.send(AppEvent::SetTitle(title)); }), ); @@ -7345,10 +7337,16 @@ impl ChatWidget { PlainHistoryCell::new(vec![line.into()]) } - fn title_confirmation_cell(name: &str) -> PlainHistoryCell { - PlainHistoryCell::new(vec![ - vec!["• ".into(), "Title set to ".into(), name.to_string().cyan()].into(), - ]) + fn title_confirmation_cell(title: Option<&str>) -> PlainHistoryCell { + let line = match title { + Some(title) => vec![ + "• ".into(), + "Title set to ".into(), + title.to_string().cyan(), + ], + None => vec!["• ".into(), "Title cleared".into()], + }; + PlainHistoryCell::new(vec![line.into()]) } pub(crate) fn add_mcp_output(&mut self) { @@ -8033,8 +8031,8 @@ impl ChatWidget { self.title_override.clone() } - pub(crate) fn set_title_override(&mut self, title: String) { - self.title_override = Some(title); + pub(crate) fn set_title_override(&mut self, title: Option) { + self.title_override = title; } pub(crate) fn is_task_running(&self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8869077992..1f83e0c065 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1772,6 +1772,24 @@ async fn title_command_sets_manual_title_without_renaming_thread() { } } +#[tokio::test] +async fn empty_title_command_clears_manual_title() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.set_title_override(Some("manual title".to_string())); + + chat.dispatch_command_with_args(SlashCommand::Title, String::new(), Vec::new()); + + assert_eq!(chat.title_override(), None); + assert_eq!(chat.thread_name(), None); + + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::SetThreadName { .. }), + "unexpected rename op: {op:?}" + ); + } +} + // ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper // filters until we see a submission op. fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 190c967a96..ba752be7c1 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,7 @@ 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 +616,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!(