diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bf0de487b..056b4c4b7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1670,6 +1670,14 @@ }, "type": "array" }, + "terminal_title": { + "default": null, + "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `spinner` and `project`.", + "items": { + "type": "string" + }, + "type": "array" + }, "theme": { "default": null, "description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3d3da045b..667d38195 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -237,6 +237,7 @@ fn config_toml_deserializes_model_availability_nux() { show_tooltips: true, alternate_screen: AltScreenMode::default(), status_line: None, + terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ @@ -921,6 +922,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { show_tooltips: true, alternate_screen: AltScreenMode::Auto, status_line: None, + terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), } @@ -4349,6 +4351,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }, @@ -4491,6 +4494,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }; @@ -4631,6 +4635,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }; @@ -4757,6 +4762,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_terminal_title: None, tui_theme: None, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 601f91b9e..03f477ba0 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -60,7 +60,7 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } -/// Produces a config edit that sets `[tui] theme = ""`. +/// Produces a config edit that sets `[tui].theme = ""`. pub fn syntax_theme_edit(name: &str) -> ConfigEdit { ConfigEdit::SetPath { segments: vec!["tui".to_string(), "theme".to_string()], @@ -68,11 +68,12 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. +/// +/// The array is written even when it is empty so "hide the status line" stays +/// distinct from "unset, so use defaults". pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { - let mut array = toml_edit::Array::new(); - for item in items { - array.push(item.clone()); - } + let array = items.iter().cloned().collect::(); ConfigEdit::SetPath { segments: vec!["tui".to_string(), "status_line".to_string()], @@ -80,6 +81,19 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { } } +/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list. +/// +/// The array is written even when it is empty so "disabled title updates" stays +/// distinct from "unset, so use defaults". +pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit { + let array = items.iter().cloned().collect::(); + + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "terminal_title".to_string()], + value: TomlItem::Value(array.into()), + } +} + pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8d0d32330..965998d11 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -359,6 +359,11 @@ pub struct Config { /// `current-dir`. pub tui_status_line: Option>, + /// Ordered list of terminal title item identifiers for the TUI. + /// + /// When unset, the TUI defaults to: `project` and `spinner`. + pub tui_terminal_title: Option>, + /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, @@ -2823,6 +2828,7 @@ impl Config { .map(|t| t.alternate_screen) .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), + tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 113dfcd2f..3b20779cd 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -752,6 +752,13 @@ pub struct Tui { #[serde(default)] pub status_line: Option>, + /// Ordered list of terminal title item identifiers. + /// + /// When set, the TUI renders the selected items into the terminal window/tab title. + /// When unset, the TUI defaults to: `spinner` and `project`. + #[serde(default)] + pub terminal_title: Option>, + /// Syntax highlighting theme name (kebab-case). /// /// When set, overrides automatic light/dark theme detection. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8995b495d..09776f0f7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -724,6 +724,8 @@ pub(crate) struct App { pub(crate) commit_anim_running: Arc, // Shared across ChatWidget instances so invalid status-line config warnings only emit once. status_line_invalid_items_warned: Arc, + // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. + terminal_title_invalid_items_warned: Arc, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -811,6 +813,7 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), } } @@ -1783,8 +1786,7 @@ impl App { let (tx, _rx) = unbounded_channel(); tx }; - self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); - self.sync_active_agent_label(); + self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx)); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -1824,6 +1826,16 @@ impl App { self.sync_active_agent_label(); } + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + let previous_terminal_title = self.chat_widget.last_terminal_title.take(); + if chat_widget.last_terminal_title.is_none() { + chat_widget.last_terminal_title = previous_terminal_title; + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + self.refresh_status_surfaces(); + } + async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. @@ -1864,8 +1876,9 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.replace_chat_widget(ChatWidget::new(init, self.server.clone())); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -1956,7 +1969,7 @@ impl App { if resume_restored_queue { self.chat_widget.maybe_send_next_queued_input(); } - self.refresh_status_line(); + self.refresh_status_surfaces(); } fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool { @@ -2078,6 +2091,7 @@ impl App { } let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -2107,6 +2121,8 @@ impl App { startup_tooltip_override, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -2143,6 +2159,8 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } @@ -2185,6 +2203,8 @@ impl App { startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -2217,6 +2237,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), @@ -2386,7 +2407,7 @@ impl App { if matches!(event, TuiEvent::Draw) { let size = tui.terminal.size()?; if size != tui.terminal.last_known_screen_size { - self.refresh_status_line(); + self.refresh_status_surfaces(); } } @@ -2514,11 +2535,11 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( + self.replace_chat_widget(ChatWidget::new_from_existing( init, resumed.thread, resumed.session_configured, - ); + )); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = @@ -2585,11 +2606,11 @@ impl App { tui, self.config.clone(), ); - self.chat_widget = ChatWidget::new_from_existing( + self.replace_chat_widget(ChatWidget::new_from_existing( init, forked.thread, forked.session_configured, - ); + )); self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = @@ -2762,15 +2783,15 @@ impl App { } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); @@ -3211,7 +3232,7 @@ impl App { } } AppEvent::PersistServiceTierSelection { service_tier } => { - self.refresh_status_line(); + self.refresh_status_surfaces(); let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) @@ -3416,7 +3437,7 @@ impl App { AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) @@ -3730,11 +3751,38 @@ impl App { } AppEvent::StatusLineBranchUpdated { cwd, branch } => { self.chat_widget.set_status_line_branch(cwd, branch); - self.refresh_status_line(); + self.refresh_status_surfaces(); } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } + AppEvent::TerminalTitleSetup { items } => { + let ids = items.iter().map(ToString::to_string).collect::>(); + let edit = codex_core::config::edit::terminal_title_items_edit(&ids); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + self.config.tui_terminal_title = Some(ids.clone()); + self.chat_widget.setup_terminal_title(items); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection"); + self.chat_widget.revert_terminal_title_setup_preview(); + self.chat_widget.add_error_message(format!( + "Failed to save terminal title items: {err}" + )); + } + } + } + AppEvent::TerminalTitleSetupPreview { items } => { + self.chat_widget.preview_terminal_title(items); + } + AppEvent::TerminalTitleSetupCancelled => { + self.chat_widget.cancel_terminal_title_setup(); + } AppEvent::SyntaxThemeSelected { name } => { let edit = codex_core::config::edit::syntax_theme_edit(&name); let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) @@ -3809,7 +3857,7 @@ impl App { self.chat_widget.handle_codex_event(event); if needs_refresh { - self.refresh_status_line(); + self.refresh_status_surfaces(); } } @@ -4190,8 +4238,8 @@ impl App { }; } - fn refresh_status_line(&mut self) { - self.chat_widget.refresh_status_line(); + fn refresh_status_surfaces(&mut self) { + self.chat_widget.refresh_status_surfaces(); } #[cfg(target_os = "windows")] @@ -4223,12 +4271,21 @@ impl App { } } +impl Drop for App { + fn drop(&mut self) { + if let Err(err) = self.chat_widget.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title on app drop"); + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -5051,6 +5108,38 @@ mod tests { } } + #[tokio::test] + async fn replace_chat_widget_preserves_terminal_title_cache_for_empty_replacement_title() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.last_terminal_title = Some("my-project | Ready".to_string()); + + let (mut replacement, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + replacement.setup_terminal_title(Vec::new()); + + app.replace_chat_widget(replacement); + + assert_eq!(app.chat_widget.last_terminal_title, None); + } + + #[tokio::test] + async fn replace_chat_widget_keeps_replacement_terminal_title_cache_when_present() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget.last_terminal_title = Some("old-project | Ready".to_string()); + + let (mut replacement, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + replacement.setup_terminal_title(vec![TerminalTitleItem::AppName]); + replacement.last_terminal_title = Some("codex".to_string()); + + app.replace_chat_widget(replacement); + + assert_eq!( + app.chat_widget.last_terminal_title, + Some("codex".to_string()) + ); + } + #[tokio::test] async fn replay_thread_snapshot_restores_pending_pastes_for_submit() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -6472,6 +6561,7 @@ guardian_approval = true enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -6532,6 +6622,7 @@ guardian_approval = true enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e2ed04669..d8a71c3da 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; +use crate::bottom_pane::TerminalTitleItem; use crate::history_cell::HistoryCell; use codex_core::config::types::ApprovalsReviewer; @@ -451,6 +452,16 @@ pub(crate) enum AppEvent { }, /// Dismiss the status-line setup UI without changing config. StatusLineSetupCancelled, + /// Apply a user-confirmed terminal-title item ordering/selection. + TerminalTitleSetup { + items: Vec, + }, + /// Apply a temporary terminal-title preview while the setup UI is open. + TerminalTitleSetupPreview { + items: Vec, + }, + /// Dismiss the terminal-title setup UI without changing config. + TerminalTitleSetupCancelled, /// Apply a user-confirmed syntax theme selection. SyntaxThemeSelected { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index de85b9ffe..80f35d5ff 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -49,6 +49,7 @@ mod request_user_input; mod status_line_setup; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; +mod title_setup; pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; @@ -100,6 +101,8 @@ pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; pub(crate) use status_line_setup::StatusLinePreviewData; pub(crate) use status_line_setup::StatusLineSetupView; +pub(crate) use title_setup::TerminalTitleItem; +pub(crate) use title_setup::TerminalTitleSetupView; mod paste_burst; mod pending_input_preview; mod pending_thread_approvals; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap new file mode 100644 index 000000000..9a6d41287 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/title_setup.rs +expression: "render_lines(&view, 84)" +--- + + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project Project name (falls back to current directory name) + [x] spinner Animated task spinner (omitted while idle or when animations… + [x] status Compact session status text (Ready, Working, Thinking) + [x] thread Current thread title (omitted until available) + [ ] app-name Codex app name + [ ] git-branch Current Git branch (omitted when unavailable) + [ ] model Current model name + [ ] task-progress Latest task progress from update_plan (omitted until availab… + + my-project ⠋ Working | Investigate flaky test + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs new file mode 100644 index 000000000..f15e8af71 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -0,0 +1,298 @@ +//! Terminal title configuration view for customizing the terminal window/tab title. +//! +//! This module provides an interactive picker for selecting which items appear +//! in the terminal title. Users can: +//! +//! - Select items +//! - Reorder items +//! - Preview the rendered title + +use itertools::Itertools; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use strum::IntoEnumIterator; +use strum_macros::Display; +use strum_macros::EnumIter; +use strum_macros::EnumString; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::multi_select_picker::MultiSelectItem; +use crate::bottom_pane::multi_select_picker::MultiSelectPicker; +use crate::render::renderable::Renderable; + +/// Available items that can be displayed in the terminal title. +#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)] +#[strum(serialize_all = "kebab_case")] +pub(crate) enum TerminalTitleItem { + /// Codex app name. + AppName, + /// Project root name, or a compact cwd fallback. + Project, + /// Animated task spinner while active. + Spinner, + /// Compact runtime status text. + Status, + /// Current thread title (if available). + Thread, + /// Current git branch (if available). + GitBranch, + /// Current model name. + Model, + /// Latest checklist task progress from `update_plan` (if available). + TaskProgress, +} + +impl TerminalTitleItem { + pub(crate) fn description(self) -> &'static str { + match self { + TerminalTitleItem::AppName => "Codex app name", + TerminalTitleItem::Project => "Project name (falls back to current directory name)", + TerminalTitleItem::Spinner => { + "Animated task spinner (omitted while idle or when animations are off)" + } + TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)", + TerminalTitleItem::Thread => "Current thread title (omitted until available)", + TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)", + TerminalTitleItem::Model => "Current model name", + TerminalTitleItem::TaskProgress => { + "Latest task progress from update_plan (omitted until available)" + } + } + } + + /// Example text used when previewing the title picker. + /// + /// These are illustrative sample values, not live data from the current + /// session. + pub(crate) fn preview_example(self) -> &'static str { + match self { + TerminalTitleItem::AppName => "codex", + TerminalTitleItem::Project => "my-project", + TerminalTitleItem::Spinner => "⠋", + TerminalTitleItem::Status => "Working", + TerminalTitleItem::Thread => "Investigate flaky test", + TerminalTitleItem::GitBranch => "feat/awesome-feature", + TerminalTitleItem::Model => "gpt-5.2-codex", + TerminalTitleItem::TaskProgress => "Tasks 2/5", + } + } + + pub(crate) fn separator_from_previous(self, previous: Option) -> &'static str { + match previous { + None => "", + Some(previous) + if previous == TerminalTitleItem::Spinner || self == TerminalTitleItem::Spinner => + { + " " + } + Some(_) => " | ", + } + } +} + +fn parse_terminal_title_items(ids: impl Iterator) -> Option> +where + T: AsRef, +{ + // Treat parsing as all-or-nothing so preview/confirm callbacks never emit + // a partially interpreted ordering. Invalid ids are ignored when building + // the picker, but once the user is interacting with the picker we only want + // to persist or preview a fully valid selection. + ids.map(|id| id.as_ref().parse::()) + .collect::, _>>() + .ok() +} + +/// Interactive view for configuring terminal-title items. +pub(crate) struct TerminalTitleSetupView { + picker: MultiSelectPicker, +} + +impl TerminalTitleSetupView { + /// Creates the terminal-title picker, preserving the configured item order first. + /// + /// Unknown configured ids are skipped here instead of surfaced inline. The + /// main TUI still warns about them when rendering the actual title, but the + /// picker itself only exposes the selectable items it can meaningfully + /// preview and persist. + pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self { + let selected_items = title_items + .into_iter() + .flatten() + .filter_map(|id| id.parse::().ok()) + .unique() + .collect_vec(); + let selected_set = selected_items + .iter() + .copied() + .collect::>(); + let items = selected_items + .into_iter() + .map(|item| Self::title_select_item(item, /*enabled*/ true)) + .chain( + TerminalTitleItem::iter() + .filter(|item| !selected_set.contains(item)) + .map(|item| Self::title_select_item(item, /*enabled*/ false)), + ) + .collect(); + + Self { + picker: MultiSelectPicker::builder( + "Configure Terminal Title".to_string(), + Some("Select which items to display in the terminal title.".to_string()), + app_event_tx, + ) + .instructions(vec![ + "Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel." + .into(), + ]) + .items(items) + .enable_ordering() + .on_preview(|items| { + let items = parse_terminal_title_items( + items + .iter() + .filter(|item| item.enabled) + .map(|item| item.id.as_str()), + )?; + let mut preview = String::new(); + let mut previous = None; + for item in items.iter().copied() { + preview.push_str(item.separator_from_previous(previous)); + preview.push_str(item.preview_example()); + previous = Some(item); + } + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } + }) + .on_change(|items, app_event| { + let Some(items) = parse_terminal_title_items( + items + .iter() + .filter(|item| item.enabled) + .map(|item| item.id.as_str()), + ) else { + return; + }; + app_event.send(AppEvent::TerminalTitleSetupPreview { items }); + }) + .on_confirm(|ids, app_event| { + let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else { + return; + }; + app_event.send(AppEvent::TerminalTitleSetup { items }); + }) + .on_cancel(|app_event| { + app_event.send(AppEvent::TerminalTitleSetupCancelled); + }) + .build(), + } + } + + fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem { + MultiSelectItem { + id: item.to_string(), + name: item.to_string(), + description: Some(item.description().to_string()), + enabled, + } + } +} + +impl BottomPaneView for TerminalTitleSetupView { + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { + self.picker.handle_key_event(key_event); + } + + fn is_complete(&self) -> bool { + self.picker.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.picker.close(); + CancellationEvent::Handled + } +} + +impl Renderable for TerminalTitleSetupView { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.picker.render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.picker.desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect(); + lines.join("\n") + } + + #[test] + fn renders_title_setup_popup() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let selected = [ + "project".to_string(), + "spinner".to_string(), + "status".to_string(), + "thread".to_string(), + ]; + let view = TerminalTitleSetupView::new(Some(&selected), tx); + assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84)); + } + + #[test] + fn parse_terminal_title_items_preserves_order() { + let items = + parse_terminal_title_items(["project", "spinner", "status", "thread"].into_iter()); + assert_eq!( + items, + Some(vec![ + TerminalTitleItem::Project, + TerminalTitleItem::Spinner, + TerminalTitleItem::Status, + TerminalTitleItem::Thread, + ]) + ); + } + + #[test] + fn parse_terminal_title_items_rejects_invalid_ids() { + let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter()); + assert_eq!(items, None); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 67d0d8e6e..a1736171c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,10 +44,15 @@ use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::TerminalTitleItem; +use crate::bottom_pane::TerminalTitleSetupView; use crate::status::RateLimitWindowDisplay; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_title::SetTerminalTitleResult; +use crate::terminal_title::clear_terminal_title; +use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::ConfigLayerSource; @@ -169,6 +174,7 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; use tracing::warn; +use unicode_segmentation::UnicodeSegmentation; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; @@ -284,6 +290,11 @@ use self::skills::find_skill_mentions_with_tool_mentions; mod realtime; use self::realtime::RealtimeConversationUiState; use self::realtime::RenderedUserMessageEvent; +mod status_surfaces; +use self::status_surfaces::CachedProjectRootName; +#[cfg(test)] +use self::status_surfaces::TERMINAL_TITLE_SPINNER_INTERVAL; +use self::status_surfaces::TerminalTitleStatusKind; use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::streaming::chunking::AdaptiveChunkingPolicy; @@ -300,6 +311,7 @@ use codex_file_search::FileMatch; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -484,6 +496,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) startup_tooltip_override: Option, // Shared latch so we only warn once about invalid status-line item IDs. pub(crate) status_line_invalid_items_warned: Arc, + // Shared latch so we only warn once about invalid terminal-title item IDs. + pub(crate) terminal_title_invalid_items_warned: Arc, pub(crate) session_telemetry: SessionTelemetry, } @@ -709,6 +723,8 @@ pub(crate) struct ChatWidget { // Guardian review keeps its own pending set so it can derive a single // footer summary from one or more in-flight review events. pending_guardian_review_status: PendingGuardianReviewStatus, + // Semantic status used for terminal-title status rendering (avoid string matching on headers). + terminal_title_status_kind: TerminalTitleStatusKind, // Previous status header to restore after a transient stream retry. retry_status_header: Option, // Set when commentary output completes; once stream queues go idle we restore the status row. @@ -771,6 +787,8 @@ pub(crate) struct ChatWidget { // later steer. This is cleared when the user submits a steer so the plan popup only appears // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, + // Latest `update_plan` checklist task counts for terminal-title rendering. + last_plan_progress: Option<(usize, usize)>, // Incremental buffer for streamed plan content. plan_delta_buffer: String, // True while a plan item is streaming. @@ -794,6 +812,21 @@ pub(crate) struct ChatWidget { session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, + // Shared latch so we only warn once about invalid terminal-title item IDs. + terminal_title_invalid_items_warned: Arc, + // Last terminal title emitted, to avoid writing duplicate OSC updates. + // + // App carries this cache across ChatWidget replacement so the next widget can + // clear a stale title when its own configuration renders no title content. + pub(crate) last_terminal_title: Option, + // Original terminal-title config captured when opening the setup UI so live preview can be + // rolled back on cancel. + terminal_title_setup_original_items: Option>>, + // Baseline instant used to animate spinner-prefixed title statuses. + terminal_title_animation_origin: Instant, + // Cached project root display name for the current cwd; avoids walking parent directories on + // frequent title/status refreshes. + status_line_project_root_name_cache: Option, // Cached git branch name for the status line (None if unknown). status_line_branch: Option, // CWD used to resolve the cached branch; change resets branch state. @@ -1089,12 +1122,15 @@ impl ChatWidget { fn update_task_running_state(&mut self) { self.bottom_pane .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); + self.refresh_terminal_title(); } fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else if self.bottom_pane.is_task_running() { + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); } } @@ -1187,6 +1223,22 @@ impl ChatWidget { StatusDetailsCapitalization::Preserve, details_max_lines, ); + let title_uses_status = self + .config + .tui_terminal_title + .as_ref() + .is_some_and(|items| items.iter().any(|item| item == "status")); + let title_uses_spinner = self + .config + .tui_terminal_title + .as_ref() + .is_none_or(|items| items.iter().any(|item| item == "spinner")); + if title_uses_status + || (title_uses_spinner + && self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing) + { + self.refresh_terminal_title(); + } } /// Convenience wrapper around [`Self::set_status`]; @@ -1213,70 +1265,6 @@ impl ChatWidget { self.bottom_pane.set_active_agent_label(active_agent_label); } - /// Recomputes footer status-line content from config and current runtime state. - /// - /// This method is the status-line orchestrator: it parses configured item identifiers, - /// warns once per session about invalid items, updates whether status-line mode is enabled, - /// schedules async git-branch lookup when needed, and renders only values that are currently - /// available. - /// - /// The omission behavior is intentional. If selected items are unavailable (for example before - /// a session id exists or before branch lookup completes), those items are skipped without - /// placeholders so the line remains compact and stable. - pub(crate) fn refresh_status_line(&mut self) { - let (items, invalid_items) = self.status_line_items_with_invalids(); - if self.thread_id.is_some() - && !invalid_items.is_empty() - && self - .status_line_invalid_items_warned - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - let label = if invalid_items.len() == 1 { - "item" - } else { - "items" - }; - let message = format!( - "Ignored invalid status line {label}: {}.", - proper_join(invalid_items.as_slice()) - ); - self.on_warning(message); - } - if !items.contains(&StatusLineItem::GitBranch) { - self.status_line_branch = None; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = false; - } - let enabled = !items.is_empty(); - self.bottom_pane.set_status_line_enabled(enabled); - if !enabled { - self.set_status_line(/*status_line*/ None); - return; - } - - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - - if items.contains(&StatusLineItem::GitBranch) && !self.status_line_branch_lookup_complete { - self.request_status_line_branch(cwd); - } - - let mut parts = Vec::new(); - for item in items { - if let Some(value) = self.status_line_value_for_item(&item) { - parts.push(value); - } - } - - let line = if parts.is_empty() { - None - } else { - Some(Line::from(parts.join(" · "))) - }; - self.set_status_line(line); - } - /// Records that status-line setup was canceled. /// /// Cancellation is intentionally side-effect free for config state; the existing configuration @@ -1292,7 +1280,45 @@ impl ChatWidget { tracing::info!("status line setup confirmed with items: {items:#?}"); let ids = items.iter().map(ToString::to_string).collect::>(); self.config.tui_status_line = Some(ids); - self.refresh_status_line(); + self.refresh_status_surfaces(); + } + + /// Applies a temporary terminal-title selection while the setup UI is open. + pub(crate) fn preview_terminal_title(&mut self, items: Vec) { + if self.terminal_title_setup_original_items.is_none() { + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + } + + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Restores the terminal title selection captured before opening the setup UI. + pub(crate) fn revert_terminal_title_setup_preview(&mut self) { + let Some(original_items) = self.terminal_title_setup_original_items.take() else { + return; + }; + + self.config.tui_terminal_title = original_items; + self.refresh_terminal_title(); + } + + /// Records that terminal-title setup was canceled and rolls back live preview changes. + pub(crate) fn cancel_terminal_title_setup(&mut self) { + tracing::info!("Terminal title setup canceled by user"); + self.revert_terminal_title_setup_preview(); + } + + /// Applies terminal-title item selection from the setup view to in-memory config. + /// + /// An empty selection persists as an explicit empty list (disables title updates). + pub(crate) fn setup_terminal_title(&mut self, items: Vec) { + tracing::info!("terminal title setup confirmed with items: {items:#?}"); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.terminal_title_setup_original_items = None; + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); } /// Stores async git-branch lookup results for the current status-line cwd. @@ -1309,17 +1335,6 @@ impl ChatWidget { self.status_line_branch_lookup_complete = true; } - /// Forces a new git-branch lookup when `GitBranch` is part of the configured status line. - fn request_status_line_branch_refresh(&mut self) { - let (items, _) = self.status_line_items_with_invalids(); - if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) { - return; - } - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - self.request_status_line_branch(cwd); - } - fn collect_runtime_metrics_delta(&mut self) { if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); @@ -1385,6 +1400,7 @@ impl ChatWidget { Constrained::allow_only(event.sandbox_policy.clone()); } self.config.approvals_reviewer = event.approvals_reviewer; + self.status_line_project_root_name_cache = None; let initial_messages = event.initial_messages.clone(); self.last_copyable_output = None; let forked_from_id = event.forked_from_id; @@ -1488,6 +1504,7 @@ impl ChatWidget { fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) { if self.thread_id == Some(event.thread_id) { self.thread_name = event.thread_name; + self.refresh_terminal_title(); self.request_redraw(); } } @@ -1659,6 +1676,7 @@ impl ChatWidget { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. @@ -1696,6 +1714,7 @@ impl ChatWidget { .set_turn_running(/*turn_running*/ true); self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; + self.last_plan_progress = None; self.plan_delta_buffer.clear(); self.plan_item_active = false; self.adaptive_chunking.reset(); @@ -1710,6 +1729,7 @@ impl ChatWidget { self.pending_status_indicator_restore = false; self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); @@ -2048,7 +2068,7 @@ impl ChatWidget { } else { self.rate_limit_snapshots_by_limit_id.clear(); } - self.refresh_status_line(); + self.refresh_status_surfaces(); } /// Finalize any active exec as failed and stop/clear agent-turn UI state. /// @@ -2353,6 +2373,17 @@ impl ChatWidget { fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.saw_plan_update_this_turn = true; + let total = update.plan.len(); + let completed = update + .plan + .iter() + .filter(|item| match &item.status { + StepStatus::Completed => true, + StepStatus::Pending | StepStatus::InProgress => false, + }) + .count(); + self.last_plan_progress = (total > 0).then_some((completed, total)); + self.refresh_terminal_title(); self.add_to_history(history_cell::new_plan_update(update)); } @@ -2671,6 +2702,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; self.set_status( "Waiting for background terminal".to_string(), command_display.clone(), @@ -2913,7 +2945,7 @@ impl ChatWidget { fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); - self.refresh_status_line(); + self.refresh_status_surfaces(); } fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { @@ -2927,6 +2959,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(message); } @@ -2968,12 +3001,15 @@ impl ChatWidget { let message = event .message .unwrap_or_else(|| "Undo in progress...".to_string()); + self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing; self.set_status_header(message); } fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.refresh_terminal_title(); let message = message.unwrap_or_else(|| { if success { "Undo completed successfully.".to_string() @@ -2993,6 +3029,7 @@ impl ChatWidget { self.retry_status_header = Some(self.current_status.header.clone()); } self.bottom_pane.ensure_status_indicator(); + self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status( message, additional_details, @@ -3003,6 +3040,9 @@ impl ChatWidget { pub(crate) fn pre_draw_tick(&mut self) { self.bottom_pane.pre_draw_tick(); + if self.should_animate_terminal_title_spinner() { + self.refresh_terminal_title(); + } } /// Handle completion of an `AgentMessage` turn item. @@ -3525,6 +3565,7 @@ impl ChatWidget { model, startup_tooltip_override, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -3616,6 +3657,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3638,6 +3680,7 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -3649,6 +3692,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -3693,6 +3741,8 @@ impl ChatWidget { .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget } @@ -3714,6 +3764,7 @@ impl ChatWidget { model, startup_tooltip_override, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -3804,6 +3855,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -3812,6 +3864,7 @@ impl ChatWidget { forked_from: None, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), @@ -3837,6 +3890,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -3870,6 +3928,8 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); widget } @@ -3894,6 +3954,7 @@ impl ChatWidget { model, startup_tooltip_override: _, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -3984,6 +4045,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -4006,6 +4068,7 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -4017,6 +4080,11 @@ impl ChatWidget { current_cwd, session_network_proxy: None, status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -4059,6 +4127,8 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_terminal_title(); + widget.refresh_terminal_title(); widget } @@ -4556,6 +4626,9 @@ impl ChatWidget { SlashCommand::DebugConfig => { self.add_debug_config_output(); } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } SlashCommand::Statusline => { self.open_status_line_setup(); } @@ -5748,188 +5821,14 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); } - /// Parses configured status-line ids into known items and collects unknown ids. - /// - /// Unknown ids are deduplicated in insertion order for warning messages. - fn status_line_items_with_invalids(&self) -> (Vec, Vec) { - let mut invalid = Vec::new(); - let mut invalid_seen = HashSet::new(); - let mut items = Vec::new(); - for id in self.configured_status_line_items() { - match id.parse::() { - Ok(item) => items.push(item), - Err(_) => { - if invalid_seen.insert(id.clone()) { - invalid.push(format!(r#""{id}""#)); - } - } - } - } - (items, invalid) - } - - fn configured_status_line_items(&self) -> Vec { - self.config.tui_status_line.clone().unwrap_or_else(|| { - DEFAULT_STATUS_LINE_ITEMS - .iter() - .map(ToString::to_string) - .collect() - }) - } - - fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) - } - - fn status_line_project_root(&self) -> Option { - let cwd = self.status_line_cwd(); - if let Some(repo_root) = get_git_repo_root(cwd) { - return Some(repo_root); - } - - self.config - .config_layer_stack - .get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) - .iter() - .find_map(|layer| match &layer.name { - ConfigLayerSource::Project { dot_codex_folder } => { - dot_codex_folder.as_path().parent().map(Path::to_path_buf) - } - _ => None, - }) - } - - fn status_line_project_root_name(&self) -> Option { - self.status_line_project_root().map(|root| { - root.file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None)) - }) - } - - /// Resets git-branch cache state when the status-line cwd changes. - /// - /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. - /// Keeping stale branch values across cwd changes would surface incorrect repository context. - fn sync_status_line_branch_state(&mut self, cwd: &Path) { - if self - .status_line_branch_cwd - .as_ref() - .is_some_and(|path| path == cwd) - { - return; - } - self.status_line_branch_cwd = Some(cwd.to_path_buf()); - self.status_line_branch = None; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = false; - } - - /// Starts an async git-branch lookup unless one is already running. - /// - /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject - /// stale completions after directory changes. - fn request_status_line_branch(&mut self, cwd: PathBuf) { - if self.status_line_branch_pending { - return; - } - self.status_line_branch_pending = true; - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let branch = current_branch_name(&cwd).await; - tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); - }); - } - - /// Resolves a display string for one configured status-line item. - /// - /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on - /// this to keep partially available status lines readable while waiting for session, token, or - /// git metadata. - fn status_line_value_for_item(&self, item: &StatusLineItem) -> Option { - match item { - StatusLineItem::ModelName => Some(self.model_display_name().to_string()), - StatusLineItem::ModelWithReasoning => { - let label = - Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); - let fast_label = if self - .should_show_fast_status(self.current_model(), self.config.service_tier) - { - " fast" - } else { - "" - }; - Some(format!("{} {label}{fast_label}", self.model_display_name())) - } - StatusLineItem::CurrentDir => { - Some(format_directory_display( - self.status_line_cwd(), - /*max_width*/ None, - )) - } - StatusLineItem::ProjectRoot => self.status_line_project_root_name(), - StatusLineItem::GitBranch => self.status_line_branch.clone(), - StatusLineItem::UsedTokens => { - let usage = self.status_line_total_usage(); - let total = usage.tokens_in_context_window(); - if total <= 0 { - None - } else { - Some(format!("{} used", format_tokens_compact(total))) - } - } - StatusLineItem::ContextRemaining => self - .status_line_context_remaining_percent() - .map(|remaining| format!("{remaining}% left")), - StatusLineItem::ContextUsed => self - .status_line_context_used_percent() - .map(|used| format!("{used}% used")), - StatusLineItem::FiveHourLimit => { - let window = self - .rate_limit_snapshots_by_limit_id - .get("codex") - .and_then(|s| s.primary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - self.status_line_limit_display(window, &label) - } - StatusLineItem::WeeklyLimit => { - let window = self - .rate_limit_snapshots_by_limit_id - .get("codex") - .and_then(|s| s.secondary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - self.status_line_limit_display(window, &label) - } - StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), - StatusLineItem::ContextWindowSize => self - .status_line_context_window_size() - .map(|cws| format!("{} window", format_tokens_compact(cws))), - StatusLineItem::TotalInputTokens => Some(format!( - "{} in", - format_tokens_compact(self.status_line_total_usage().input_tokens) - )), - StatusLineItem::TotalOutputTokens => Some(format!( - "{} out", - format_tokens_compact(self.status_line_total_usage().output_tokens) - )), - StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), - StatusLineItem::FastMode => Some( - if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { - "Fast on".to_string() - } else { - "Fast off".to_string() - }, - ), - } + fn open_terminal_title_setup(&mut self) { + let configured_terminal_title_items = self.configured_terminal_title_items(); + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + let view = TerminalTitleSetupView::new( + Some(configured_terminal_title_items.as_slice()), + self.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); } fn status_line_context_window_size(&self) -> Option { @@ -8187,6 +8086,7 @@ impl ChatWidget { self.session_header.set_model(effective.model()); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); + self.refresh_terminal_title(); } fn model_display_name(&self) -> &str { @@ -9288,8 +9188,8 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool { impl Drop for ChatWidget { fn drop(&mut self) { - self.reset_realtime_conversation_state(); self.stop_rate_limit_poller(); + self.reset_realtime_conversation_state(); } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs new file mode 100644 index 000000000..5888b3b1b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -0,0 +1,660 @@ +//! Status-line and terminal-title rendering helpers for `ChatWidget`. +//! +//! Keeping this logic in a focused submodule makes the additive title/status +//! behavior easier to review without paging through the rest of `chatwidget.rs`. + +use super::*; + +pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"]; +pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +/// Compact runtime states that can be rendered into the terminal title. +/// +/// This is intentionally smaller than the full status-header vocabulary. The +/// title needs short, stable labels, so callers map richer lifecycle events +/// onto one of these buckets before rendering. +pub(super) enum TerminalTitleStatusKind { + Working, + WaitingForBackgroundTerminal, + Undoing, + #[default] + Thinking, +} + +#[derive(Debug)] +/// Parsed status-surface configuration for one refresh pass. +/// +/// The status line and terminal title share some expensive or stateful inputs +/// (notably git branch lookup and invalid-item warnings). This snapshot lets one +/// refresh pass compute those shared concerns once, then render both surfaces +/// from the same selection set. +struct StatusSurfaceSelections { + status_line_items: Vec, + invalid_status_line_items: Vec, + terminal_title_items: Vec, + invalid_terminal_title_items: Vec, +} + +impl StatusSurfaceSelections { + fn uses_git_branch(&self) -> bool { + self.status_line_items.contains(&StatusLineItem::GitBranch) + || self + .terminal_title_items + .contains(&TerminalTitleItem::GitBranch) + } +} + +#[derive(Clone, Debug)] +/// Cached project-root display name keyed by the cwd used for the last lookup. +/// +/// Terminal-title refreshes can happen very frequently, so the title path avoids +/// repeatedly walking up the filesystem to rediscover the same project root name +/// while the working directory is unchanged. +pub(super) struct CachedProjectRootName { + pub(super) cwd: PathBuf, + pub(super) root_name: Option, +} + +impl ChatWidget { + fn status_surface_selections(&self) -> StatusSurfaceSelections { + let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids(); + let (terminal_title_items, invalid_terminal_title_items) = + self.terminal_title_items_with_invalids(); + StatusSurfaceSelections { + status_line_items, + invalid_status_line_items, + terminal_title_items, + invalid_terminal_title_items, + } + } + + fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) { + if self.thread_id.is_some() + && !invalid_items.is_empty() + && self + .status_line_invalid_items_warned + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let label = if invalid_items.len() == 1 { + "item" + } else { + "items" + }; + let message = format!( + "Ignored invalid status line {label}: {}.", + proper_join(invalid_items) + ); + self.on_warning(message); + } + } + + fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) { + if self.thread_id.is_some() + && !invalid_items.is_empty() + && self + .terminal_title_invalid_items_warned + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let label = if invalid_items.len() == 1 { + "item" + } else { + "items" + }; + let message = format!( + "Ignored invalid terminal title {label}: {}.", + proper_join(invalid_items) + ); + self.on_warning(message); + } + } + + fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) { + if !selections.uses_git_branch() { + self.status_line_branch = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + return; + } + + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + if !self.status_line_branch_lookup_complete { + self.request_status_line_branch(cwd); + } + } + + fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { + let enabled = !selections.status_line_items.is_empty(); + self.bottom_pane.set_status_line_enabled(enabled); + if !enabled { + self.set_status_line(/*status_line*/ None); + return; + } + + let mut parts = Vec::new(); + for item in &selections.status_line_items { + if let Some(value) = self.status_line_value_for_item(item) { + parts.push(value); + } + } + + let line = if parts.is_empty() { + None + } else { + Some(Line::from(parts.join(" · "))) + }; + self.set_status_line(line); + } + + /// Clears the terminal title Codex most recently wrote, if any. + /// + /// This does not attempt to restore the shell or terminal's previous title; + /// it only clears the managed title and updates the cache after a successful + /// OSC write. + pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> { + if self.last_terminal_title.is_some() { + clear_terminal_title()?; + self.last_terminal_title = None; + } + + Ok(()) + } + + /// Renders and applies the terminal title for one parsed selection snapshot. + /// + /// Empty selections clear the managed title. Non-empty selections render the + /// current values in configured order, skip unavailable segments, and cache + /// the last successfully written title so redundant OSC writes are avoided. + /// When the `spinner` item is present in an animated running state, this also + /// schedules the next frame so the spinner keeps advancing. + fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) { + if selections.terminal_title_items.is_empty() { + if let Err(err) = self.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title"); + } + return; + } + + let now = Instant::now(); + let mut previous = None; + let title = selections + .terminal_title_items + .iter() + .copied() + .filter_map(|item| { + self.terminal_title_value_for_item(item, now) + .map(|value| (item, value)) + }) + .fold(String::new(), |mut title, (item, value)| { + title.push_str(item.separator_from_previous(previous)); + title.push_str(&value); + previous = Some(item); + title + }); + let title = (!title.is_empty()).then_some(title); + let should_animate_spinner = + self.should_animate_terminal_title_spinner_with_selections(selections); + if self.last_terminal_title == title { + if should_animate_spinner { + self.frame_requester + .schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL); + } + return; + } + match title { + Some(title) => match set_terminal_title(&title) { + Ok(SetTerminalTitleResult::Applied) => { + self.last_terminal_title = Some(title); + } + Ok(SetTerminalTitleResult::NoVisibleContent) => { + if let Err(err) = self.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title"); + } + } + Err(err) => { + tracing::debug!(error = %err, "failed to set terminal title"); + } + }, + None => { + if let Err(err) = self.clear_managed_terminal_title() { + tracing::debug!(error = %err, "failed to clear terminal title"); + } + } + } + + if should_animate_spinner { + self.frame_requester + .schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL); + } + } + + /// Recomputes both status surfaces from one shared config snapshot. + /// + /// This is the common refresh entrypoint for the footer status line and the + /// terminal title. It parses both configurations once, emits invalid-item + /// warnings once, synchronizes shared cached state (such as git-branch + /// lookup), then renders each surface from that shared snapshot. + pub(crate) fn refresh_status_surfaces(&mut self) { + let selections = self.status_surface_selections(); + self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items); + self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); + self.sync_status_surface_shared_state(&selections); + self.refresh_status_line_from_selections(&selections); + self.refresh_terminal_title_from_selections(&selections); + } + + /// Recomputes and emits the terminal title from config and runtime state. + pub(crate) fn refresh_terminal_title(&mut self) { + let selections = self.status_surface_selections(); + self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); + self.sync_status_surface_shared_state(&selections); + self.refresh_terminal_title_from_selections(&selections); + } + + pub(super) fn request_status_line_branch_refresh(&mut self) { + let selections = self.status_surface_selections(); + if !selections.uses_git_branch() { + return; + } + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + self.request_status_line_branch(cwd); + } + + /// Parses configured status-line ids into known items and collects unknown ids. + /// + /// Unknown ids are deduplicated in insertion order for warning messages. + fn status_line_items_with_invalids(&self) -> (Vec, Vec) { + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut items = Vec::new(); + for id in self.configured_status_line_items() { + match id.parse::() { + Ok(item) => items.push(item), + Err(_) => { + if invalid_seen.insert(id.clone()) { + invalid.push(format!(r#""{id}""#)); + } + } + } + } + (items, invalid) + } + + pub(super) fn configured_status_line_items(&self) -> Vec { + self.config.tui_status_line.clone().unwrap_or_else(|| { + DEFAULT_STATUS_LINE_ITEMS + .iter() + .map(ToString::to_string) + .collect() + }) + } + + /// Parses configured terminal-title ids into known items and collects unknown ids. + /// + /// Unknown ids are deduplicated in insertion order for warning messages. + fn terminal_title_items_with_invalids(&self) -> (Vec, Vec) { + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut items = Vec::new(); + for id in self.configured_terminal_title_items() { + match id.parse::() { + Ok(item) => items.push(item), + Err(_) => { + if invalid_seen.insert(id.clone()) { + invalid.push(format!(r#""{id}""#)); + } + } + } + } + (items, invalid) + } + + /// Returns the configured terminal-title ids, or the default ordering when unset. + pub(super) fn configured_terminal_title_items(&self) -> Vec { + self.config.tui_terminal_title.clone().unwrap_or_else(|| { + DEFAULT_TERMINAL_TITLE_ITEMS + .iter() + .map(ToString::to_string) + .collect() + }) + } + + fn status_line_cwd(&self) -> &Path { + self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + } + + /// Resolves the project root associated with `cwd`. + /// + /// Git repository root wins when available. Otherwise we fall back to the + /// nearest project config layer so non-git projects can still surface a + /// stable project label. + fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option { + if let Some(repo_root) = get_git_repo_root(cwd) { + return Some(repo_root); + } + + self.config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .iter() + .find_map(|layer| match &layer.name { + ConfigLayerSource::Project { dot_codex_folder } => { + dot_codex_folder.as_path().parent().map(Path::to_path_buf) + } + _ => None, + }) + } + + fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option { + self.status_line_project_root_for_cwd(cwd).map(|root| { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None)) + }) + } + + /// Returns a cached project-root display name for the active cwd. + fn status_line_project_root_name(&mut self) -> Option { + let cwd = self.status_line_cwd().to_path_buf(); + if let Some(cache) = &self.status_line_project_root_name_cache + && cache.cwd == cwd + { + return cache.root_name.clone(); + } + + let root_name = self.status_line_project_root_name_for_cwd(&cwd); + self.status_line_project_root_name_cache = Some(CachedProjectRootName { + cwd, + root_name: root_name.clone(), + }); + root_name + } + + /// Produces the terminal-title `project` value. + /// + /// This prefers the cached project-root name and falls back to the current + /// directory name when no project root can be inferred. + fn terminal_title_project_name(&mut self) -> Option { + let project = self.status_line_project_root_name().or_else(|| { + let cwd = self.status_line_cwd(); + Some( + cwd.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(cwd, /*max_width*/ None)), + ) + })?; + Some(Self::truncate_terminal_title_part( + project, /*max_chars*/ 24, + )) + } + + /// Resets git-branch cache state when the status-line cwd changes. + /// + /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. + /// Keeping stale branch values across cwd changes would surface incorrect repository context. + fn sync_status_line_branch_state(&mut self, cwd: &Path) { + if self + .status_line_branch_cwd + .as_ref() + .is_some_and(|path| path == cwd) + { + return; + } + self.status_line_branch_cwd = Some(cwd.to_path_buf()); + self.status_line_branch = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + } + + /// Starts an async git-branch lookup unless one is already running. + /// + /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject + /// stale completions after directory changes. + fn request_status_line_branch(&mut self, cwd: PathBuf) { + if self.status_line_branch_pending { + return; + } + self.status_line_branch_pending = true; + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let branch = current_branch_name(&cwd).await; + tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); + }); + } + + /// Resolves a display string for one configured status-line item. + /// + /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on + /// this to keep partially available status lines readable while waiting for session, token, or + /// git metadata. + pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option { + match item { + StatusLineItem::ModelName => Some(self.model_display_name().to_string()), + StatusLineItem::ModelWithReasoning => { + let label = + Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); + let fast_label = if self + .should_show_fast_status(self.current_model(), self.config.service_tier) + { + " fast" + } else { + "" + }; + Some(format!("{} {label}{fast_label}", self.model_display_name())) + } + StatusLineItem::CurrentDir => { + Some(format_directory_display( + self.status_line_cwd(), + /*max_width*/ None, + )) + } + StatusLineItem::ProjectRoot => self.status_line_project_root_name(), + StatusLineItem::GitBranch => self.status_line_branch.clone(), + StatusLineItem::UsedTokens => { + let usage = self.status_line_total_usage(); + let total = usage.tokens_in_context_window(); + if total <= 0 { + None + } else { + Some(format!("{} used", format_tokens_compact(total))) + } + } + StatusLineItem::ContextRemaining => self + .status_line_context_remaining_percent() + .map(|remaining| format!("{remaining}% left")), + StatusLineItem::ContextUsed => self + .status_line_context_used_percent() + .map(|used| format!("{used}% used")), + StatusLineItem::FiveHourLimit => { + let window = self + .rate_limit_snapshots_by_limit_id + .get("codex") + .and_then(|s| s.primary.as_ref()); + let label = window + .and_then(|window| window.window_minutes) + .map(get_limits_duration) + .unwrap_or_else(|| "5h".to_string()); + self.status_line_limit_display(window, &label) + } + StatusLineItem::WeeklyLimit => { + let window = self + .rate_limit_snapshots_by_limit_id + .get("codex") + .and_then(|s| s.secondary.as_ref()); + let label = window + .and_then(|window| window.window_minutes) + .map(get_limits_duration) + .unwrap_or_else(|| "weekly".to_string()); + self.status_line_limit_display(window, &label) + } + StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), + StatusLineItem::ContextWindowSize => self + .status_line_context_window_size() + .map(|cws| format!("{} window", format_tokens_compact(cws))), + StatusLineItem::TotalInputTokens => Some(format!( + "{} in", + format_tokens_compact(self.status_line_total_usage().input_tokens) + )), + StatusLineItem::TotalOutputTokens => Some(format!( + "{} out", + format_tokens_compact(self.status_line_total_usage().output_tokens) + )), + StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), + StatusLineItem::FastMode => Some( + if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + "Fast on".to_string() + } else { + "Fast off".to_string() + }, + ), + } + } + + /// Resolves one configured terminal-title item into a displayable segment. + /// + /// Returning `None` means "omit this segment for now" so callers can keep + /// the configured order while hiding values that are not yet available. + fn terminal_title_value_for_item( + &mut self, + item: TerminalTitleItem, + now: Instant, + ) -> Option { + match item { + TerminalTitleItem::AppName => Some("codex".to_string()), + TerminalTitleItem::Project => self.terminal_title_project_name(), + TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now), + TerminalTitleItem::Status => Some(self.terminal_title_status_text()), + TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(Self::truncate_terminal_title_part( + trimmed.to_string(), + /*max_chars*/ 48, + )) + } + }), + TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| { + Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32) + }), + TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part( + self.model_display_name().to_string(), + /*max_chars*/ 32, + )), + TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(), + } + } + + /// Computes the compact runtime status label used by the terminal title. + /// + /// Startup takes precedence over normal task states, and idle state renders + /// as `Ready` regardless of the last active status bucket. + pub(super) fn terminal_title_status_text(&self) -> String { + if self.mcp_startup_status.is_some() { + return "Starting".to_string(); + } + + match self.terminal_title_status_kind { + TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => { + "Ready".to_string() + } + TerminalTitleStatusKind::WaitingForBackgroundTerminal + if !self.bottom_pane.is_task_running() => + { + "Ready".to_string() + } + TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => { + "Ready".to_string() + } + TerminalTitleStatusKind::Working => "Working".to_string(), + TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(), + TerminalTitleStatusKind::Undoing => "Undoing".to_string(), + TerminalTitleStatusKind::Thinking => "Thinking".to_string(), + } + } + + pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option { + if !self.config.animations { + return None; + } + + if !self.terminal_title_has_active_progress() { + return None; + } + + Some(self.terminal_title_spinner_frame_at(now).to_string()) + } + + fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str { + let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin); + let frame_index = + (elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize; + TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()] + } + + fn terminal_title_uses_spinner(&self) -> bool { + self.config + .tui_terminal_title + .as_ref() + .is_none_or(|items| items.iter().any(|item| item == "spinner")) + } + + fn terminal_title_has_active_progress(&self) -> bool { + self.mcp_startup_status.is_some() + || self.bottom_pane.is_task_running() + || self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing + } + + pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { + self.config.animations + && self.terminal_title_uses_spinner() + && self.terminal_title_has_active_progress() + } + + fn should_animate_terminal_title_spinner_with_selections( + &self, + selections: &StatusSurfaceSelections, + ) -> bool { + self.config.animations + && selections + .terminal_title_items + .contains(&TerminalTitleItem::Spinner) + && self.terminal_title_has_active_progress() + } + + /// Formats the last `update_plan` progress snapshot for terminal-title display. + pub(super) fn terminal_title_task_progress(&self) -> Option { + let (completed, total) = self.last_plan_progress?; + if total == 0 { + return None; + } + Some(format!("Tasks {completed}/{total}")) + } + + /// Truncates a title segment by grapheme cluster and appends `...` when needed. + pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + + let mut graphemes = value.graphemes(true); + let head: String = graphemes.by_ref().take(max_chars).collect(); + if graphemes.next().is_none() || max_chars <= 3 { + return head; + } + + let mut truncated = head.graphemes(true).take(max_chars - 3).collect::(); + truncated.push_str("..."); + truncated + } +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4f216ba2e..05adf3d30 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1777,6 +1777,7 @@ async fn helpers_are_available_and_do_not_panic() { model: Some(resolved_model), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; let mut w = ChatWidget::new(init, thread_manager); @@ -1896,6 +1897,7 @@ async fn make_chatwidget_manual( reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, @@ -1919,6 +1921,7 @@ async fn make_chatwidget_manual( had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, last_separator_elapsed_secs: None, @@ -1930,6 +1933,11 @@ async fn make_chatwidget_manual( current_cwd: None, session_network_proxy: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + last_terminal_title: None, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, @@ -5716,6 +5724,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; @@ -5766,6 +5775,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; @@ -6322,6 +6332,50 @@ async fn undo_started_hides_interrupt_hint() { ); } +#[tokio::test] +async fn undo_completed_clears_terminal_title_undo_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]); + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.handle_codex_event(Event { + id: "turn-undo".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string())); + + chat.handle_codex_event(Event { + id: "turn-undo".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: true, + message: None, + }), + }); + + assert_eq!(chat.last_terminal_title, Some("Ready".to_string())); +} + +#[tokio::test] +async fn undo_started_refreshes_default_spinner_project_title() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.refresh_terminal_title(); + let project = chat + .last_terminal_title + .clone() + .expect("default title should include a project name"); + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.handle_codex_event(Event { + id: "turn-undo".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}"))); +} + /// The commit picker shows only commit subjects (no timestamps). #[tokio::test] async fn review_commit_picker_shows_subjects_without_timestamps() { @@ -10505,16 +10559,20 @@ async fn status_line_invalid_items_warn_once() { ]); chat.thread_id = Some(ThreadId::new()); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("bogus_item"), + rendered.contains(r#""bogus_item""#), "warning cell missing invalid item content: {rendered}" ); + assert!( + !rendered.contains(r#"\"bogus_item\""#), + "warning cell should render plain quotes, not escaped quotes: {rendered}" + ); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let cells = drain_insert_history(&mut rx); assert!( cells.is_empty(), @@ -10522,6 +10580,257 @@ async fn status_line_invalid_items_warn_once() { ); } +#[tokio::test] +async fn terminal_title_invalid_items_warn_once() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_terminal_title = Some(vec![ + "status".to_string(), + "bogus_item".to_string(), + "bogus_item".to_string(), + ]); + chat.thread_id = Some(ThreadId::new()); + + chat.refresh_status_surfaces(); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(r#""bogus_item""#), + "warning cell missing invalid item content: {rendered}" + ); + assert!( + !rendered.contains(r#"\"bogus_item\""#), + "warning cell should render plain quotes, not escaped quotes: {rendered}" + ); + + chat.refresh_status_surfaces(); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected invalid terminal title warning to emit only once" + ); +} + +#[tokio::test] +async fn terminal_title_setup_cancel_reverts_live_preview() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let original = chat.config.tui_terminal_title.clone(); + + chat.open_terminal_title_setup(); + chat.preview_terminal_title(vec![TerminalTitleItem::Thread, TerminalTitleItem::Status]); + + assert_eq!( + chat.config.tui_terminal_title, + Some(vec!["thread".to_string(), "status".to_string()]) + ); + assert_eq!( + chat.terminal_title_setup_original_items, + Some(original.clone()) + ); + + chat.cancel_terminal_title_setup(); + + assert_eq!(chat.config.tui_terminal_title, original); + assert_eq!(chat.terminal_title_setup_original_items, None); +} + +#[tokio::test] +async fn terminal_title_status_uses_waiting_label_for_background_terminal_when_animations_disabled() +{ + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = false; + chat.on_task_started(); + terminal_interaction(&mut chat, "call-1", "proc-1", ""); + + assert_eq!(chat.terminal_title_status_text(), "Waiting"); +} + +#[tokio::test] +async fn terminal_title_status_uses_plain_labels_for_transient_states_when_animations_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = false; + + chat.mcp_startup_status = Some(std::collections::HashMap::new()); + assert_eq!(chat.terminal_title_status_text(), "Starting"); + + chat.mcp_startup_status = None; + chat.on_task_started(); + assert_eq!(chat.terminal_title_status_text(), "Working"); + + chat.handle_codex_event(Event { + id: "undo-1".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { + message: Some("Undoing changes".to_string()), + }), + }); + assert_eq!(chat.terminal_title_status_text(), "Undoing"); + + chat.on_agent_reasoning_delta("**Planning**\nmore".to_string()); + assert_eq!(chat.terminal_title_status_text(), "Thinking"); +} + +#[tokio::test] +async fn default_terminal_title_items_are_spinner_then_project() { + let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + assert_eq!( + chat.configured_terminal_title_items(), + vec!["spinner".to_string(), "project".to_string()] + ); +} + +#[tokio::test] +async fn terminal_title_can_render_app_name_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_terminal_title = Some(vec!["app-name".to_string()]); + + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some("codex".to_string())); +} + +#[tokio::test] +async fn default_terminal_title_refreshes_when_spinner_state_changes() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + + chat.config.tui_terminal_title = None; + let cwd = chat + .current_cwd + .clone() + .unwrap_or_else(|| chat.config.cwd.clone()); + let project = get_git_repo_root(&cwd) + .map(|root| { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&root, None)) + }) + .or_else(|| { + chat.config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .iter() + .find_map(|layer| match &layer.name { + ConfigLayerSource::Project { dot_codex_folder } => { + dot_codex_folder.as_path().parent().map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(path, None)) + }) + } + _ => None, + }) + }) + .unwrap_or_else(|| { + cwd.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&cwd, None)) + }); + chat.last_terminal_title = Some(project.clone()); + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}"))); +} + +#[tokio::test] +async fn terminal_title_spinner_item_renders_when_animations_enabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Working; + chat.terminal_title_animation_origin = Instant::now(); + + assert_eq!( + chat.terminal_title_spinner_text_at(chat.terminal_title_animation_origin), + Some("⠋".to_string()) + ); + assert_eq!( + chat.terminal_title_spinner_text_at( + chat.terminal_title_animation_origin + TERMINAL_TITLE_SPINNER_INTERVAL, + ), + Some("⠙".to_string()) + ); +} + +#[tokio::test] +async fn terminal_title_uses_spaces_around_spinner_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec![ + "project".to_string(), + "spinner".to_string(), + "status".to_string(), + "thread".to_string(), + ]); + chat.thread_name = Some("Investigate flaky test".to_string()); + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Working; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + chat.refresh_terminal_title(); + + let title = chat + .last_terminal_title + .clone() + .expect("expected terminal title"); + assert!(title.contains(" ⠋ Working | ")); + assert!(!title.contains("| ⠋")); + assert!(!title.contains("⠋ |")); +} + +#[tokio::test] +async fn terminal_title_shows_spinner_and_undoing_without_task_running() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Undoing; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + + assert!(!chat.bottom_pane.is_task_running()); + + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string())); +} + +#[tokio::test] +async fn terminal_title_reschedules_spinner_when_title_text_is_unchanged() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let (frame_requester, mut frame_schedule_rx) = FrameRequester::test_observable(); + chat.frame_requester = frame_requester; + chat.config.animations = true; + chat.config.tui_terminal_title = Some(vec!["spinner".to_string()]); + chat.bottom_pane.set_task_running(true); + chat.terminal_title_status_kind = TerminalTitleStatusKind::Working; + chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1); + chat.last_terminal_title = Some("⠋".to_string()); + + chat.refresh_terminal_title(); + + assert!(frame_schedule_rx.try_recv().is_ok()); +} + +#[tokio::test] +async fn on_task_started_resets_terminal_title_task_progress() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.last_plan_progress = Some((2, 5)); + + chat.on_task_started(); + + assert_eq!(chat.last_plan_progress, None); + assert_eq!(chat.terminal_title_task_progress(), None); +} + +#[test] +fn terminal_title_part_truncation_preserves_grapheme_clusters() { + let value = "ab👩‍💻cdefg".to_string(); + let truncated = ChatWidget::truncate_terminal_title_part(value, 7); + assert_eq!(truncated, "ab👩‍💻c..."); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -10530,7 +10839,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() { chat.status_line_branch_lookup_complete = true; chat.config.tui_status_line = Some(vec!["model_name".to_string()]); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!(chat.status_line_branch, None); assert!(!chat.status_line_branch_pending); @@ -10555,6 +10864,25 @@ async fn status_line_branch_refreshes_after_turn_complete() { assert!(chat.status_line_branch_pending); } +#[tokio::test] +async fn status_line_branch_refreshes_after_turn_complete_when_terminal_title_uses_git_branch() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_status_line = Some(Vec::new()); + chat.config.tui_terminal_title = Some(vec!["git-branch".to_string()]); + chat.status_line_branch_lookup_complete = true; + chat.status_line_branch_pending = false; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + assert!(chat.status_line_branch_pending); +} + #[tokio::test] async fn status_line_branch_refreshes_after_interrupt() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -10578,11 +10906,11 @@ async fn status_line_fast_mode_renders_on_and_off() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!(status_line_text(&chat), Some("Fast off".to_string())); chat.set_service_tier(Some(ServiceTier::Fast)); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!(status_line_text(&chat), Some("Fast on".to_string())); } @@ -10595,7 +10923,7 @@ async fn status_line_fast_mode_footer_snapshot() { chat.show_welcome_banner = false; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); chat.set_service_tier(Some(ServiceTier::Fast)); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let width = 80; let height = chat.desired_height(width); @@ -10618,7 +10946,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!( status_line_text(&chat), @@ -10626,7 +10954,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { ); chat.set_model("gpt-5.3-codex"); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); assert_eq!( status_line_text(&chat), @@ -10650,7 +10978,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); - chat.refresh_status_line(); + chat.refresh_status_surfaces(); let width = 80; let height = chat.desired_height(width); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5ecb87dd2..b4841a779 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -125,6 +125,7 @@ mod status_indicator_widget; mod streaming; mod style; mod terminal_palette; +mod terminal_title; mod text_formatting; mod theme_picker; mod tooltips; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d83135c2f..d30eeb2f4 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,6 +38,7 @@ pub enum SlashCommand { Mention, Status, DebugConfig, + Title, Statusline, Theme, Mcp, @@ -85,6 +86,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", + SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Theme => "choose a syntax highlighting theme", SlashCommand::Ps => "list background terminals", @@ -177,6 +179,7 @@ impl SlashCommand { SlashCommand::Agent | SlashCommand::MultiAgents => true, SlashCommand::Statusline => false, SlashCommand::Theme => false, + SlashCommand::Title => false, } } diff --git a/codex-rs/tui/src/terminal_title.rs b/codex-rs/tui/src/terminal_title.rs new file mode 100644 index 000000000..e4f009cb0 --- /dev/null +++ b/codex-rs/tui/src/terminal_title.rs @@ -0,0 +1,205 @@ +//! Terminal-title output helpers for the TUI. +//! +//! This module owns the low-level OSC title write path and the sanitization +//! that happens immediately before we emit it. It is intentionally narrow: +//! callers decide when the title should change and whether an empty title means +//! "leave the old title alone" or "clear the title Codex last wrote". +//! This module does not attempt to read or restore the terminal's previous +//! title because that is not portable across terminals. +//! +//! Sanitization is necessary because title content is assembled from untrusted +//! text sources such as model output, thread names, project paths, and config. +//! Before we place that text inside an OSC sequence, we strip: +//! - control characters that could terminate or reshape the escape sequence +//! - bidi/invisible formatting codepoints that can visually reorder or hide +//! text (the same family of issues discussed in Trojan Source writeups) +//! - redundant whitespace that would make titles noisy or hard to scan + +use std::fmt; +use std::io; +use std::io::IsTerminal; +use std::io::stdout; + +use crossterm::Command; +use ratatui::crossterm::execute; + +const MAX_TERMINAL_TITLE_CHARS: usize = 240; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum SetTerminalTitleResult { + /// A sanitized title was written, or stdout is not a terminal so no write was needed. + Applied, + /// Sanitization removed every visible character, so no title was emitted. + /// + /// This is distinct from clearing the title. Callers decide whether an + /// empty post-sanitization value should result in no-op behavior, clearing + /// the title Codex manages, or some other fallback. + NoVisibleContent, +} + +/// Writes a sanitized OSC window-title sequence to stdout. +/// +/// The input is treated as untrusted display text: control characters, +/// invisible formatting characters, and redundant whitespace are removed before +/// the title is emitted. If sanitization removes all visible content, the +/// function returns [`SetTerminalTitleResult::NoVisibleContent`] instead of +/// clearing the title because clearing and restoring are policy decisions for +/// higher-level callers. Mechanically, sanitization collapses whitespace runs +/// to single spaces, drops disallowed codepoints, and bounds the result to +/// [`MAX_TERMINAL_TITLE_CHARS`] visible characters before writing OSC 0. +pub(crate) fn set_terminal_title(title: &str) -> io::Result { + if !stdout().is_terminal() { + return Ok(SetTerminalTitleResult::Applied); + } + + let title = sanitize_terminal_title(title); + if title.is_empty() { + return Ok(SetTerminalTitleResult::NoVisibleContent); + } + + execute!(stdout(), SetWindowTitle(title))?; + Ok(SetTerminalTitleResult::Applied) +} + +/// Clears the current terminal title by writing an empty OSC title payload. +/// +/// This clears the visible title; it does not restore whatever title the shell +/// or a previous program may have set before Codex started managing the title. +pub(crate) fn clear_terminal_title() -> io::Result<()> { + if !stdout().is_terminal() { + return Ok(()); + } + + execute!(stdout(), SetWindowTitle(String::new())) +} + +#[derive(Debug, Clone)] +struct SetWindowTitle(String); + +impl Command for SetWindowTitle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + // xterm/ctlseqs documents OSC 0/2 title sequences with ST (ESC \) termination. + // Most terminals also accept BEL for compatibility, but ST is the canonical form. + write!(f, "\x1b]0;{}\x1b\\", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(std::io::Error::other( + "tried to execute SetWindowTitle using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// Normalizes untrusted title text into a single bounded display line. +/// +/// This removes terminal control characters, strips invisible/bidi formatting +/// characters, collapses any whitespace run into a single ASCII space, and +/// truncates after [`MAX_TERMINAL_TITLE_CHARS`] emitted characters. +fn sanitize_terminal_title(title: &str) -> String { + let mut sanitized = String::new(); + let mut chars_written = 0; + let mut pending_space = false; + + for ch in title.chars() { + if ch.is_whitespace() { + pending_space = !sanitized.is_empty(); + continue; + } + + if is_disallowed_terminal_title_char(ch) { + continue; + } + + if pending_space && chars_written < MAX_TERMINAL_TITLE_CHARS { + sanitized.push(' '); + chars_written += 1; + pending_space = false; + } + + if chars_written >= MAX_TERMINAL_TITLE_CHARS { + break; + } + + sanitized.push(ch); + chars_written += 1; + } + + sanitized +} + +/// Returns whether `ch` should be dropped from terminal-title output. +/// +/// This includes both plain control characters and a curated set of invisible +/// formatting codepoints. The bidi entries here cover the Trojan-Source-style +/// text-reordering controls that can make a title render misleadingly relative +/// to its underlying byte sequence. +fn is_disallowed_terminal_title_char(ch: char) -> bool { + if ch.is_control() { + return true; + } + + // Strip Trojan-Source-related bidi controls plus common non-rendering + // formatting characters so title text cannot smuggle terminal control + // semantics or visually misleading content. + matches!( + ch, + '\u{00AD}' + | '\u{034F}' + | '\u{061C}' + | '\u{180E}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{FFF9}'..='\u{FFFB}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{E0100}'..='\u{E01EF}' + ) +} + +#[cfg(test)] +mod tests { + use super::MAX_TERMINAL_TITLE_CHARS; + use super::SetWindowTitle; + use super::sanitize_terminal_title; + use crossterm::Command; + use pretty_assertions::assert_eq; + + #[test] + fn sanitizes_terminal_title() { + let sanitized = + sanitize_terminal_title(" Project\t|\nWorking\x1b\x07\u{009D}\u{009C} | Thread "); + assert_eq!(sanitized, "Project | Working | Thread"); + } + + #[test] + fn strips_invisible_format_chars_from_terminal_title() { + let sanitized = sanitize_terminal_title( + "Pro\u{202E}j\u{2066}e\u{200F}c\u{061C}t\u{200B} \u{FEFF}T\u{2060}itle", + ); + assert_eq!(sanitized, "Project Title"); + } + + #[test] + fn truncates_terminal_title() { + let input = "a".repeat(MAX_TERMINAL_TITLE_CHARS + 10); + let sanitized = sanitize_terminal_title(&input); + assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); + } + + #[test] + fn writes_osc_title_with_string_terminator() { + let mut out = String::new(); + SetWindowTitle("hello".to_string()) + .write_ansi(&mut out) + .expect("encode terminal title"); + assert_eq!(out, "\x1b]0;hello\x1b\\"); + } +} diff --git a/codex-rs/tui/src/tui/frame_requester.rs b/codex-rs/tui/src/tui/frame_requester.rs index d7e54d82c..8c2b43b08 100644 --- a/codex-rs/tui/src/tui/frame_requester.rs +++ b/codex-rs/tui/src/tui/frame_requester.rs @@ -65,6 +65,17 @@ impl FrameRequester { frame_schedule_tx: tx, } } + + /// Create a requester and expose its raw schedule queue for assertions. + pub(crate) fn test_observable() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + FrameRequester { + frame_schedule_tx: tx, + }, + rx, + ) + } } /// A scheduler for coalescing frame draw requests and notifying the TUI event loop.