diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1fe9d0d499..3fbcc733ec 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -718,6 +718,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, @@ -1747,6 +1755,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")] @@ -1785,6 +1795,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(), @@ -1845,6 +1856,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, @@ -1856,6 +1869,7 @@ impl App { AppRunControl::Exit(reason) => break reason, } }; + tui.set_title_context(None)?; tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), @@ -2124,6 +2138,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(); @@ -3731,6 +3749,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 9f9a8d6de1..eec14dd89e 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(String), /// 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 1f774f2c74..868f1b5255 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -17,7 +17,11 @@ use std::collections::HashSet; // Hide alias commands in the default popup list so each unique action appears once. // `quit` is an alias of `exit`, so we skip `quit` here. // `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; +const ALIAS_COMMANDS: &[SlashCommand] = &[ + SlashCommand::Quit, + SlashCommand::Approvals, + SlashCommand::Title, +]; /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -477,6 +481,18 @@ mod tests { assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } + #[test] + fn title_hidden_in_empty_filter_but_shown_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 85b301386b..e25455e7ff 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -82,6 +82,14 @@ mod tests { ); } + #[test] + fn title_command_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("title", all_enabled_flags()), + Some(SlashCommand::Title) + ); + } + #[test] fn fast_command_is_hidden_when_disabled() { let mut flags = all_enabled_flags(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 51f39c1f2a..d684689dd9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -607,6 +607,7 @@ pub(crate) struct ChatWidget { pending_status_indicator_restore: 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 @@ -2913,6 +2914,7 @@ impl ChatWidget { pending_status_indicator_restore: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, queued_user_messages: VecDeque::new(), queued_message_edit_binding, @@ -3093,6 +3095,7 @@ impl ChatWidget { pending_status_indicator_restore: 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, @@ -3262,6 +3265,7 @@ impl ChatWidget { pending_status_indicator_restore: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, queued_user_messages: VecDeque::new(), queued_message_edit_binding, @@ -3607,6 +3611,9 @@ impl ChatWidget { self.otel_manager.counter("codex.thread.rename", 1, &[]); self.show_rename_prompt(); } + SlashCommand::Title => { + self.show_title_prompt(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -3936,6 +3943,16 @@ 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); + self.add_boxed_history(Box::new(cell)); + self.set_title_override(name); + self.request_redraw(); + } SlashCommand::Plan if !trimmed.is_empty() => { self.dispatch_command(cmd); if self.active_mode_kind() != ModeKind::Plan { @@ -4030,6 +4047,28 @@ 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".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); + tx.send(AppEvent::InsertHistoryCell(Box::new(cell))); + tx.send(AppEvent::SetTitle(name)); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -7306,6 +7345,12 @@ 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(), + ]) + } + pub(crate) fn add_mcp_output(&mut self) { let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( self.config.codex_home.clone(), @@ -7984,6 +8029,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: String) { + self.title_override = Some(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 a720559b5c..8869077992 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1715,6 +1715,7 @@ async fn make_chatwidget_manual( pending_status_indicator_restore: false, thread_id: None, thread_name: None, + title_override: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, @@ -1754,6 +1755,23 @@ 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:?}" + ); + } +} + // 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 4bf3737033..f4a3b218e3 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 9d521d1aa6..190c967a96 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())); + } +}