diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 34d8d85a927..fae20fe6950 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -729,6 +729,14 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +fn normalize_title_context(title_override: Option) -> Option { + title_override + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -1916,6 +1924,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())?; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] @@ -1954,6 +1964,7 @@ impl App { ) .await?; if let AppRunControl::Exit(exit_reason) = control { + tui.set_title_context(None)?; return Ok(AppExitInfo { token_usage: app.token_usage(), thread_id: app.chat_widget.thread_id(), @@ -2014,6 +2025,8 @@ impl App { AppRunControl::Continue } }; + let title_context = normalize_title_context(app.chat_widget.title_override()); + tui.set_title_context(title_context.as_deref())?; if App::should_stop_waiting_for_initial_session( waiting_for_initial_session_configured, app.primary_thread_id, @@ -2025,6 +2038,7 @@ impl App { AppRunControl::Exit(reason) => break reason, } }; + tui.set_title_context(None)?; tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), @@ -2296,6 +2310,10 @@ impl App { } } } + AppEvent::SetTitle(title) => { + self.chat_widget.set_title_override(title); + tui.frame_requester().schedule_frame(); + } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -3881,6 +3899,14 @@ mod tests { ); } + #[test] + fn normalize_title_context_uses_manual_title_when_present() { + assert_eq!( + normalize_title_context(Some("Named thread".to_string())), + Some("Named thread".to_string()) + ); + } + #[test] fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() { let mut wait_for_initial_session = diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9f9a8d6de1e..c8f91b846b7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -161,6 +161,7 @@ pub(crate) enum AppEvent { }, InsertHistoryCell(Box), + SetTitle(Option), /// Apply rollback semantics to local transcript cells. /// diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1f774f2c749..de138619ea7 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -477,6 +477,18 @@ mod tests { assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } + #[test] + fn title_shown_in_empty_filter_and_for_prefix() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Title))); + + popup.on_composer_text_change("/ti".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Title))); + } + #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 85b301386bb..34215a33c3d 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -69,17 +69,17 @@ mod tests { } #[test] - fn debug_command_still_resolves_for_dispatch() { - let cmd = find_builtin_command("debug-config", all_enabled_flags()); - assert_eq!(cmd, Some(SlashCommand::DebugConfig)); - } - - #[test] - fn clear_command_resolves_for_dispatch() { - assert_eq!( - find_builtin_command("clear", all_enabled_flags()), - Some(SlashCommand::Clear) - ); + fn known_commands_resolve_for_dispatch() { + for (name, expected) in [ + ("debug-config", SlashCommand::DebugConfig), + ("clear", SlashCommand::Clear), + ("title", SlashCommand::Title), + ] { + assert_eq!( + find_builtin_command(name, all_enabled_flags()), + Some(expected) + ); + } } #[test] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aff0a9f410b..1dc9689806c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -615,6 +615,7 @@ pub(crate) struct ChatWidget { suppress_queue_autosend: bool, thread_id: Option, thread_name: Option, + title_override: Option, forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -3216,6 +3217,7 @@ impl ChatWidget { suppress_queue_autosend: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, queued_user_messages: VecDeque::new(), pending_steers: VecDeque::new(), @@ -3399,6 +3401,7 @@ impl ChatWidget { suppress_queue_autosend: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, @@ -3574,6 +3577,7 @@ impl ChatWidget { suppress_queue_autosend: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, queued_user_messages: VecDeque::new(), pending_steers: VecDeque::new(), @@ -3939,6 +3943,9 @@ impl ChatWidget { .counter("codex.thread.rename", 1, &[]); self.show_rename_prompt(); } + SlashCommand::Title => { + self.show_title_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -4270,6 +4277,13 @@ impl ChatWidget { .send(AppEvent::CodexOp(Op::SetThreadName { name })); self.bottom_pane.drain_pending_submission_state(); } + 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(title); + self.request_redraw(); + } SlashCommand::Plan if !trimmed.is_empty() => { self.dispatch_command(cmd); if self.active_mode_kind() != ModeKind::Plan { @@ -4364,6 +4378,23 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + fn show_title_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Set title".to_string(), + "Type a title and press Enter. Leave blank to clear it.".to_string(), + None, + Box::new(move |name: String| { + 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(title)); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -7794,6 +7825,18 @@ impl ChatWidget { PlainHistoryCell::new(vec![line.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) { let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( self.config.codex_home.clone(), @@ -8494,6 +8537,14 @@ impl ChatWidget { self.thread_name.clone() } + pub(crate) fn title_override(&self) -> Option { + self.title_override.clone() + } + + pub(crate) fn set_title_override(&mut self, title: Option) { + self.title_override = title; + } + /// 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/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 98e5005c7a2..89fb8c5c10c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1860,6 +1860,7 @@ async fn make_chatwidget_manual( suppress_queue_autosend: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, @@ -1901,6 +1902,41 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +#[tokio::test] +async fn title_command_sets_manual_title_without_renaming_thread() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command_with_args(SlashCommand::Title, "manual title".to_string(), Vec::new()); + + assert_eq!(chat.title_override(), Some("manual title".to_string())); + assert_eq!(chat.thread_name(), None); + + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::SetThreadName { .. }), + "unexpected rename op: {op:?}" + ); + } +} + +#[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/slash_command.rs b/codex-rs/tui/src/slash_command.rs index b4669645d12..f90b8b09aca 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -24,6 +24,7 @@ pub enum SlashCommand { Skills, Review, Rename, + Title, New, Resume, Fork, @@ -72,6 +73,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", + SlashCommand::Title => "set the terminal title", SlashCommand::Resume => "resume a saved chat", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", @@ -124,6 +126,7 @@ impl SlashCommand { self, SlashCommand::Review | SlashCommand::Rename + | SlashCommand::Title | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::SandboxReadRoot @@ -156,6 +159,7 @@ impl SlashCommand { SlashCommand::Diff | SlashCommand::Copy | SlashCommand::Rename + | SlashCommand::Title | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 9d521d1aa6f..190c967a965 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -3,6 +3,7 @@ use std::future::Future; use std::io::IsTerminal; use std::io::Result; use std::io::Stdout; +use std::io::Write; use std::io::stdin; use std::io::stdout; use std::panic; @@ -58,6 +59,39 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +const DEFAULT_TERMINAL_TITLE: &str = "Codex"; + +fn format_terminal_title(context: Option<&str>) -> String { + let context = context + .map(|text| { + text.chars() + .filter(|c| !c.is_control()) + .collect::() + .trim() + .to_string() + }) + .filter(|text| !text.is_empty()); + + match context { + Some(context) => format!("{DEFAULT_TERMINAL_TITLE} - {context}"), + None => DEFAULT_TERMINAL_TITLE.to_string(), + } +} + +fn write_terminal_title( + writer: &mut impl Write, + current_title: &mut Option, + context: Option<&str>, +) -> Result<()> { + let title = format_terminal_title(context); + if current_title.as_ref() == Some(&title) { + return Ok(()); + } + write!(writer, "\x1b]0;{title}\x07")?; + writer.flush()?; + *current_title = Some(title); + Ok(()) +} pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; @@ -255,6 +289,7 @@ pub struct Tui { notification_backend: Option, // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) alt_screen_enabled: bool, + current_title: Option, } impl Tui { @@ -283,6 +318,7 @@ impl Tui { enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), alt_screen_enabled: true, + current_title: None, } } @@ -295,6 +331,12 @@ impl Tui { self.notification_backend = Some(detect_backend(method)); } + pub fn set_title_context(&mut self, context: Option<&str>) -> Result<()> { + let current_title = &mut self.current_title; + let backend = self.terminal.backend_mut(); + write_terminal_title(backend, current_title, context) + } + pub fn frame_requester(&self) -> FrameRequester { self.frame_requester.clone() } @@ -544,3 +586,47 @@ impl Tui { Ok(None) } } + +#[cfg(test)] +mod tests { + use super::DEFAULT_TERMINAL_TITLE; + use super::format_terminal_title; + use super::write_terminal_title; + use pretty_assertions::assert_eq; + + #[test] + fn terminal_title_defaults_to_codex() { + assert_eq!(format_terminal_title(None), DEFAULT_TERMINAL_TITLE); + assert_eq!(format_terminal_title(Some(" ")), DEFAULT_TERMINAL_TITLE); + } + + #[test] + fn terminal_title_includes_thread_name() { + assert_eq!( + format_terminal_title(Some("fix title syncing")), + "Codex - fix title syncing" + ); + } + + #[test] + fn terminal_title_strips_control_characters() { + assert_eq!( + format_terminal_title(Some("hello\x1b\t\n\r\u{7}world")), + "Codex - helloworld" + ); + } + + #[test] + fn terminal_title_write_is_deduplicated() { + let mut output = Vec::new(); + let mut current_title = None; + + write_terminal_title(&mut output, &mut current_title, Some("plan")) + .expect("first title write should succeed"); + write_terminal_title(&mut output, &mut current_title, Some("plan")) + .expect("duplicate title write should succeed"); + + assert_eq!(output, b"\x1b]0;Codex - plan\x07"); + assert_eq!(current_title, Some("Codex - plan".to_string())); + } +}