From 2c54d4b1608d6305362dd2c776ee6ac83a37852a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 26 Mar 2026 14:59:12 -0300 Subject: [PATCH] feat(tui): add terminal title support to tui app server (#15860) ## TR;DR Replicates the `/title` command from `tui` to `tui_app_server`. ## Problem The classic `tui` crate supports customizing the terminal window/tab title via `/title`, but the `tui_app_server` crate does not. Users on the app-server path have no way to configure what their terminal title shows (project name, status, spinner, thread, etc.), making it harder to identify Codex sessions across tabs or windows. ## Mental model The terminal title is a *status surface* -- conceptually parallel to the footer status line. Both surfaces are configurable lists of items, both share expensive inputs (git branch lookup, project root discovery), and both must be refreshed at the same lifecycle points. This change ports the classic `tui`'s design verbatim: 1. **`terminal_title.rs`** owns the low-level OSC write path and input sanitization. It strips control characters and bidi/invisible codepoints before placing untrusted text (model output, thread names, project paths) inside an escape sequence. 2. **`title_setup.rs`** defines `TerminalTitleItem` (the 8 configurable items) and `TerminalTitleSetupView` (the interactive picker that wraps `MultiSelectPicker`). 3. **`status_surfaces.rs`** is the shared refresh pipeline. It parses both surface configs once per refresh, warns about invalid items once per session, synchronizes the git-branch cache, then renders each surface from the same `StatusSurfaceSelections` snapshot. 4. **`chatwidget.rs`** sets `TerminalTitleStatusKind` at each state transition (Working, Thinking, Undoing, WaitingForBackgroundTerminal) and calls `refresh_terminal_title()` whenever relevant state changes. 5. **`app.rs`** handles the three setup events (confirm/preview/cancel), persists config via `ConfigEditsBuilder`, and clears the managed title on `Drop`. ## Non-goals - **Restoring the previous terminal title on exit.** There is no portable way to read the terminal's current title, so `Drop` clears the managed title rather than restoring it. - **Sharing code between `tui` and `tui_app_server`.** The implementation is a parallel copy, matching the existing pattern for the status-line feature. Extracting a shared crate is future work. ## Tradeoffs - **Duplicate code across crates.** The three core files (`terminal_title.rs`, `title_setup.rs`, `status_surfaces.rs`) are byte-for-byte copies from the classic `tui`. This was chosen for consistency with the existing status-line port and to avoid coupling the two crates at the dependency level. Future changes must be applied in both places. - **`status_surfaces.rs` is large (~660 lines).** It absorbs logic that previously lived inline in `chatwidget.rs` (status-line refresh, git branch management, project root discovery) plus all new terminal-title logic. This consolidation trades file size for a single place where both surfaces are coordinated. - **Spinner scheduling on every refresh.** The terminal title spinner (when active) schedules a frame every 100ms. This is the same pattern the status-indicator spinner already uses; the overhead is a timer registration, not a redraw. ## Architecture ``` /title command -> SlashCommand::Title -> open_terminal_title_setup() -> TerminalTitleSetupView (MultiSelectPicker) -> on_change: AppEvent::TerminalTitleSetupPreview -> preview_terminal_title() -> on_confirm: AppEvent::TerminalTitleSetup -> ConfigEditsBuilder + setup_terminal_title() -> on_cancel: AppEvent::TerminalTitleSetupCancelled -> cancel_terminal_title_setup() Runtime title refresh: state change (turn start, reasoning, undo, plan update, thread rename, ...) -> set terminal_title_status_kind -> refresh_terminal_title() -> status_surface_selections() (parse configs, collect invalids) -> refresh_terminal_title_from_selections() -> terminal_title_value_for_item() for each configured item -> assemble title string with separators -> skip if identical to last_terminal_title (dedup OSC writes) -> set_terminal_title() (sanitize + OSC 0 write) -> schedule spinner frame if animating Widget replacement: replace_chat_widget_with_app_server_thread() -> transfer last_terminal_title from old widget to new -> avoids redundant OSC clear+rewrite on session switch ``` ## Observability - Invalid terminal-title item IDs in config emit a one-per-session warning via `on_warning()` (gated by `terminal_title_invalid_items_warned` `AtomicBool`). - OSC write failures are logged at `tracing::debug` level. - Config persistence failures are logged at `tracing::error` and surfaced to the user via `add_error_message()`. ## Tests - `terminal_title.rs`: 4 unit tests covering sanitization (control chars, bidi codepoints, truncation) and OSC output format. - `title_setup.rs`: 3 tests covering setup view snapshot rendering, parse order preservation, and invalid-ID rejection. - `chatwidget/tests.rs`: Updated test helpers with new fields; existing tests continue to pass. --------- Co-authored-by: Eric Traut --- codex-rs/tui_app_server/src/app.rs | 58 ++ codex-rs/tui_app_server/src/app_event.rs | 12 + .../tui_app_server/src/bottom_pane/mod.rs | 3 + ...up__tests__terminal_title_setup_basic.snap | 21 + .../src/bottom_pane/title_setup.rs | 319 +++++++++ codex-rs/tui_app_server/src/chatwidget.rs | 390 ++++------- .../src/chatwidget/status_surfaces.rs | 662 ++++++++++++++++++ .../tui_app_server/src/chatwidget/tests.rs | 10 + codex-rs/tui_app_server/src/lib.rs | 1 + codex-rs/tui_app_server/src/slash_command.rs | 3 + codex-rs/tui_app_server/src/terminal_title.rs | 224 ++++++ 11 files changed, 1455 insertions(+), 248 deletions(-) create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/title_setup.rs create mode 100644 codex-rs/tui_app_server/src/chatwidget/status_surfaces.rs create mode 100644 codex-rs/tui_app_server/src/terminal_title.rs diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index dbef11ebc..95e231b97 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -948,6 +948,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, @@ -1053,6 +1055,7 @@ impl App { model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), } } @@ -2728,6 +2731,14 @@ impl App { /// This helper copies every known nickname/role from `AgentNavigationState` into the /// replacement widget so that replayed collab items render agent names immediately. fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + // Transfer the last-written terminal title to the replacement widget + // so it knows what OSC title is currently displayed. Without this, the + // new widget would redundantly clear and rewrite the same title, causing + // a visible flicker in some terminals. + 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; + } for (thread_id, entry) in self.agent_navigation.ordered_threads() { chat_widget.set_collab_agent_metadata( thread_id, @@ -3195,6 +3206,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 = @@ -3226,6 +3238,8 @@ impl App { model: Some(model.clone()), startup_tooltip_override, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), session_telemetry: session_telemetry.clone(), }; (ChatWidget::new_with_app_event(init), Some(started)) @@ -3259,6 +3273,8 @@ impl App { model: config.model.clone(), startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), session_telemetry: session_telemetry.clone(), }; (ChatWidget::new_with_app_event(init), Some(resumed)) @@ -3297,6 +3313,8 @@ impl App { model: config.model.clone(), startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), session_telemetry: session_telemetry.clone(), }; (ChatWidget::new_with_app_event(init), Some(forked)) @@ -3332,6 +3350,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(), @@ -5007,6 +5026,33 @@ impl App { 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) @@ -5649,6 +5695,14 @@ fn mcp_inventory_maps_from_statuses(statuses: Vec) -> McpInvent (tools, resources, resource_templates, auth_statuses) } +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::*; @@ -5978,6 +6032,7 @@ mod tests { model: Some(model), startup_tooltip_override: None, status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(), session_telemetry: app.session_telemetry.clone(), }); @@ -8122,6 +8177,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(), @@ -8175,6 +8231,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(), @@ -9306,6 +9363,7 @@ guardian_approval = true model: Some(app.chat_widget.current_model().to_string()), startup_tooltip_override: None, status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(), session_telemetry: app.session_telemetry.clone(), }); app.replace_chat_widget(replacement); diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index f5284fd61..e6d8bdecd 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -28,6 +28,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; @@ -547,6 +548,17 @@ 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 { name: String, diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index dd90bc11b..78cf6a235 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -47,6 +47,7 @@ mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; +mod title_setup; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; pub(crate) use app_link_view::AppLinkView; @@ -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_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap new file mode 100644 index 000000000..0316b957b --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/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_app_server/src/bottom_pane/title_setup.rs b/codex-rs/tui_app_server/src/bottom_pane/title_setup.rs new file mode 100644 index 000000000..6714d8b3a --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/title_setup.rs @@ -0,0 +1,319 @@ +//! 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. +/// +/// Variants serialize to kebab-case identifiers (e.g. `AppName` -> `"app-name"`) +/// via strum. These identifiers are persisted in user config files, so renaming +/// or removing a variant is a breaking config change. +#[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", + } + } + + /// Returns the separator to place before this item in a rendered title. + /// + /// The spinner gets a plain space on either side so it reads as + /// `my-project Working` rather than `my-project | | Working`. + /// All other adjacent items are joined with ` | `. + 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); + } + + #[test] + fn parse_terminal_title_items_accepts_kebab_case_variants() { + let items = parse_terminal_title_items(["app-name", "git-branch"].into_iter()); + assert_eq!( + items, + Some(vec![ + TerminalTitleItem::AppName, + TerminalTitleItem::GitBranch, + ]) + ); + } +} diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 7f6a9b4c6..636349aae 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -48,6 +48,8 @@ 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::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::model_catalog::ModelCatalog; @@ -57,6 +59,9 @@ use crate::status::StatusAccountDisplay; 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::AppSummary; @@ -350,6 +355,9 @@ use self::plugins::PluginsCacheState; mod realtime; use self::realtime::RealtimeConversationUiState; use self::realtime::RenderedUserMessageEvent; +mod status_surfaces; +use self::status_surfaces::CachedProjectRootName; +use self::status_surfaces::TerminalTitleStatusKind; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; @@ -361,12 +369,14 @@ 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; use codex_utils_approval_presets::ApprovalPreset; use codex_utils_approval_presets::builtin_approval_presets; use strum::IntoEnumIterator; +use unicode_segmentation::UnicodeSegmentation; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; @@ -547,6 +557,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, } @@ -801,6 +813,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. + 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. @@ -869,6 +883,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. @@ -892,6 +908,22 @@ 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. + pub(crate) last_terminal_title: Option, + // Original terminal-title config captured when the setup UI opens. + // + // The outer `Option` tracks whether a setup session is active (`Some`) + // or not (`None`). The inner `Option>` mirrors the shape + // of `config.tui_terminal_title` (which is `None` when using defaults). + // On cancel or persist-failure the inner value is restored to config; + // on confirm the outer is set to `None` to end the session. + 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 keyed by cwd for status/title rendering. + 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. @@ -1584,12 +1616,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")); } } @@ -1682,6 +1717,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`]; @@ -1719,57 +1770,7 @@ impl ChatWidget { /// 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); + self.refresh_status_surfaces(); } /// Records that status-line setup was canceled. @@ -1790,6 +1791,46 @@ impl ChatWidget { self.refresh_status_line(); } + /// 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 config that was active before the setup UI + /// opened, undoing any preview changes. No-op if no setup session is active. + 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(); + } + + /// Dismisses the terminal-title setup UI and reverts to the pre-setup config. + pub(crate) fn cancel_terminal_title_setup(&mut self) { + tracing::info!("Terminal title setup canceled by user"); + self.revert_terminal_title_setup_preview(); + } + + /// Commits a confirmed terminal-title selection, ending the setup session. + /// + /// After this call, `revert_terminal_title_setup_preview` becomes a no-op + /// because the original config snapshot is discarded. + 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. /// /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch @@ -1802,17 +1843,7 @@ impl ChatWidget { self.status_line_branch = branch; self.status_line_branch_pending = false; 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); + self.refresh_status_surfaces(); } fn collect_runtime_metrics_delta(&mut self) { @@ -1885,6 +1916,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; self.last_copyable_output = None; let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); @@ -1899,6 +1931,7 @@ impl ChatWidget { mask.reasoning_effort = Some(event.reasoning_effort); } self.refresh_model_display(); + self.refresh_status_surfaces(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); @@ -2005,6 +2038,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(); } } @@ -2162,6 +2196,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. @@ -2199,6 +2234,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(); @@ -2213,6 +2249,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(); @@ -2948,6 +2985,7 @@ impl ChatWidget { self.update_task_running_state(); if restored_task_running && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_terminal_title(); } self.refresh_pending_input_preview(); self.request_redraw(); @@ -2959,6 +2997,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)); } @@ -3277,6 +3326,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(), @@ -3738,6 +3788,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); } @@ -3780,6 +3831,7 @@ 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); } @@ -3787,6 +3839,8 @@ impl ChatWidget { 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() @@ -3806,6 +3860,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, @@ -3816,6 +3871,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. @@ -4352,6 +4410,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()); @@ -4446,6 +4505,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, @@ -4470,6 +4530,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, @@ -4481,6 +4542,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, @@ -4525,6 +4591,7 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_status_surfaces(); widget } @@ -5033,6 +5100,9 @@ impl ChatWidget { SlashCommand::DebugConfig => { self.add_debug_config_output(); } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } SlashCommand::Statusline => { self.open_status_line_setup(); } @@ -7102,6 +7172,16 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + 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 open_theme_picker(&mut self) { let codex_home = codex_core::config::find_codex_home().ok(); let terminal_width = self @@ -7116,192 +7196,6 @@ 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_deref() - .unwrap_or(self.config.cwd.as_path()) - } - - 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 status_line_context_window_size(&self) -> Option { self.token_info .as_ref() diff --git a/codex-rs/tui_app_server/src/chatwidget/status_surfaces.rs b/codex-rs/tui_app_server/src/chatwidget/status_surfaces.rs new file mode 100644 index 000000000..d80003cc5 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/status_surfaces.rs @@ -0,0 +1,662 @@ +//! 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::*; + +/// Items shown in the terminal title when the user has not configured a +/// custom selection. Intentionally minimal: spinner + project name. +pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"]; + +/// Braille-pattern dot-spinner frames for the terminal title animation. +pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Time between spinner frame advances in the terminal title. +pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); + +/// 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. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +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) + } +} + +/// 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. +#[derive(Clone, Debug)] +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) { + parse_items_with_invalids(self.configured_status_line_items()) + } + + 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) { + parse_items_with_invalids(self.configured_terminal_title_items()) + } + + /// 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_deref() + .unwrap_or(self.config.cwd.as_path()) + } + + /// 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 + } +} + +fn parse_items_with_invalids(ids: impl IntoIterator) -> (Vec, Vec) +where + T: std::str::FromStr, +{ + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut items = Vec::new(); + for id in ids { + match id.parse::() { + Ok(item) => items.push(item), + Err(_) => { + if invalid_seen.insert(id.clone()) { + invalid.push(format!(r#""{id}""#)); + } + } + } + } + (items, invalid) +} diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index a74b43fa8..8191acc3d 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -1946,6 +1946,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_with_app_event(init); @@ -2048,6 +2049,7 @@ async fn make_chatwidget_manual( stream_controller: None, plan_stream_controller: None, pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, last_copyable_output: None, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), @@ -2099,6 +2101,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, @@ -2110,6 +2113,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, @@ -6848,6 +6856,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, }; @@ -6892,6 +6901,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, }; diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 1e4086685..22de8b38c 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -141,6 +141,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_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index 228120400..ec624d3fb 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -38,6 +38,7 @@ pub enum SlashCommand { Mention, Status, DebugConfig, + Title, Statusline, Theme, Mcp, @@ -86,6 +87,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", @@ -180,6 +182,7 @@ impl SlashCommand { SlashCommand::Agent | SlashCommand::MultiAgents => true, SlashCommand::Statusline => false, SlashCommand::Theme => false, + SlashCommand::Title => false, } } diff --git a/codex-rs/tui_app_server/src/terminal_title.rs b/codex-rs/tui_app_server/src/terminal_title.rs new file mode 100644 index 000000000..86c51e4b6 --- /dev/null +++ b/codex-rs/tui_app_server/src/terminal_title.rs @@ -0,0 +1,224 @@ +//! 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; + +/// Practical upper bound on title length, measured in Rust `char`s. +/// +/// Most terminals silently truncate titles beyond a few hundred characters. +/// 240 leaves headroom for the OSC framing bytes while keeping titles +/// readable in tab bars and window managers. +const MAX_TERMINAL_TITLE_CHARS: usize = 240; + +/// Outcome of a [`set_terminal_title`] call. +#[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() { + // Only set pending if we've already written content; this + // strips leading whitespace without an extra trim pass. + pending_space = !sanitized.is_empty(); + continue; + } + + if is_disallowed_terminal_title_char(ch) { + continue; + } + + if pending_space { + let remaining = MAX_TERMINAL_TITLE_CHARS.saturating_sub(chars_written); + if remaining > 1 { + 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 truncation_prefers_visible_char_over_pending_space() { + let input = format!("{} b", "a".repeat(MAX_TERMINAL_TITLE_CHARS - 1)); + let sanitized = sanitize_terminal_title(&input); + assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); + assert_eq!(sanitized.chars().last(), Some('b')); + } + + #[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\\"); + } +}