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\\"); + } +}