diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 114ded97e934..1b9066d07fda 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -575,7 +575,9 @@ pub struct Tui { /// Ordered list of terminal title item identifiers. /// /// When set, the TUI renders the selected items into the terminal window/tab title. - /// When unset, the TUI defaults to: `spinner` and `project`. + /// When unset, the TUI defaults to: `activity` and `project`. + /// The `activity` item spins while working and shows an action-required + /// message when blocked on the user. #[serde(default)] pub terminal_title: Option>, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e4d156c54097..81d39eeef81f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2266,7 +2266,7 @@ }, "terminal_title": { "default": null, - "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `spinner` and `project`.", + "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `activity` and `project`. The `activity` item spins while working and shows an action-required message when blocked on the user.", "items": { "type": "string" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 47f2e4dd9f72..63af876ac9ad 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -455,7 +455,9 @@ pub struct Config { /// Ordered list of terminal title item identifiers for the TUI. /// - /// When unset, the TUI defaults to: `project` and `spinner`. + /// When unset, the TUI defaults to: `activity` and `project`. + /// The `activity` item spins while working and shows an action-required + /// message when blocked on the user. pub tui_terminal_title: Option>, /// Syntax highlighting theme override (kebab-case name). diff --git a/codex-rs/tui/src/bottom_pane/action_required_title.rs b/codex-rs/tui/src/bottom_pane/action_required_title.rs new file mode 100644 index 000000000000..c4b6e9418e46 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/action_required_title.rs @@ -0,0 +1,25 @@ +use super::TerminalTitleItem; + +pub(crate) const ACTION_REQUIRED_PREVIEW_PREFIX: &str = "[ ! ] Action Required"; + +pub(crate) fn build_action_required_title_text( + prefix: &str, + items: I, + excluded_items: &[TerminalTitleItem], + mut value_for: F, +) -> String +where + I: IntoIterator, + F: FnMut(TerminalTitleItem) -> Option, +{ + let mut parts = vec![prefix.to_string()]; + for item in items { + if item == TerminalTitleItem::Spinner || excluded_items.contains(&item) { + continue; + } + if let Some(value) = value_for(item) { + parts.push(value); + } + } + parts.join(" | ") +} diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 4ffdb304d70f..a87ccddb73b9 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -494,6 +494,10 @@ impl BottomPaneView for AppLinkView { self.complete = true; true } + + fn terminal_title_requires_action(&self) -> bool { + self.is_tool_suggestion() + } } impl crate::render::renderable::Renderable for AppLinkView { @@ -630,6 +634,52 @@ mod tests { ); } + #[test] + fn regular_app_link_does_not_require_terminal_title_action() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: None, + suggestion_type: None, + elicitation_target: None, + }, + tx, + ); + + assert!(!view.terminal_title_requires_action()); + } + + #[test] + fn tool_suggestion_requires_terminal_title_action() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + instructions: "Enable this app to use it for the current request.".to_string(), + url: "https://example.test/google-calendar".to_string(), + is_installed: true, + is_enabled: false, + suggest_reason: Some("Plan and reference events from your calendar".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Enable), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert!(view.terminal_title_requires_action()); + } + #[test] fn toggle_action_sends_set_app_enabled_and_updates_label() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ce3b04b33bd6..e5ab6cb0b5b7 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -528,6 +528,10 @@ impl BottomPaneView for ApprovalOverlay { fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { self.dismiss_resolved_request(request) } + + fn terminal_title_requires_action(&self) -> bool { + true + } } impl Renderable for ApprovalOverlay { diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index bb168d3db188..cbd1b16908b6 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -121,4 +121,13 @@ pub(crate) trait BottomPaneView: Renderable { fn dismiss_app_server_request(&mut self, _request: &ResolvedAppServerRequest) -> bool { false } + + /// Whether this view means the session is blocked waiting for the user. + /// + /// Views that return `true` surface an "Action Required" terminal title + /// instead of the normal working spinner so terminal tabs clearly show that + /// Codex needs user input. + fn terminal_title_requires_action(&self) -> bool { + false + } } diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 3c0abec24d24..5de9b6437990 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -1622,6 +1622,10 @@ impl BottomPaneView for McpServerElicitationOverlay { } } + fn terminal_title_requires_action(&self) -> bool { + true + } + fn on_ctrl_c(&mut self) -> CancellationEvent { if !self.current_field_is_select() && !self.composer.current_text_with_pending().is_empty() { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a2067fb7e7ad..96dce10fc9bb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -43,6 +43,7 @@ use ratatui::layout::Rect; use ratatui::text::Line; use std::time::Duration; +mod action_required_title; mod app_link_view; mod approval_overlay; mod mcp_server_elicitation; @@ -51,6 +52,8 @@ mod request_user_input; mod status_line_setup; mod status_surface_preview; mod title_setup; +pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX; +pub(crate) use action_required_title::build_action_required_title_text; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; pub(crate) use app_link_view::AppLinkView; @@ -945,6 +948,11 @@ impl BottomPane { self.is_task_running } + pub(crate) fn terminal_title_requires_action(&self) -> bool { + self.active_view() + .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) + } + #[cfg(test)] pub(crate) fn has_active_view(&self) -> bool { !self.view_stack.is_empty() diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 4e152fbd1b3b..c11b5c1ed5f3 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1240,6 +1240,10 @@ impl BottomPaneView for RequestUserInputOverlay { } } + fn terminal_title_requires_action(&self) -> bool { + true + } + fn on_ctrl_c(&mut self) -> CancellationEvent { if self.confirm_unanswered_active() { self.close_unanswered_confirmation(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap index c8cdb76be027..7dadf02b35d5 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -9,7 +9,7 @@ expression: "render_lines(&view, 84)" Type to search > › [x] project-name Project name (falls back to current directory name) - [x] spinner Animated task spinner (omitted while idle or when animat… + [x] activity Spinner while working, action-required message while blo… [x] run-state Compact session run-state text (Ready, Working, Thinking) [x] thread-title Current thread title (omitted when unavailable) [ ] app-name Codex app name @@ -17,5 +17,5 @@ expression: "render_lines(&view, 84)" [ ] git-branch Current Git branch (omitted when unavailable) [ ] context-remaining Percentage of context window remaining (omitted when unk… - my-project ⠋ Working | thread title + [ ! ] Action Required | my-project | Working | thread title Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index 51b14976f0bf..a6e83a3ff9a5 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -18,8 +18,10 @@ use strum_macros::EnumString; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ACTION_REQUIRED_PREVIEW_PREFIX; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::build_action_required_title_text; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; @@ -41,7 +43,8 @@ pub(crate) enum TerminalTitleItem { Project, /// Current working directory path. CurrentDir, - /// Animated task spinner while active. + /// Terminal-title activity indicator while active. + #[strum(to_string = "activity", serialize = "spinner")] Spinner, /// Compact runtime run-state text. #[strum(to_string = "run-state", serialize = "status")] @@ -88,7 +91,7 @@ impl TerminalTitleItem { TerminalTitleItem::Project => "Project name (falls back to current directory name)", TerminalTitleItem::CurrentDir => "Current working directory", TerminalTitleItem::Spinner => { - "Animated task spinner (omitted while idle or when animations are off)" + "Spinner while working, action-required message while blocked." } TerminalTitleItem::Status => { "Compact session run-state text (Ready, Working, Thinking)" @@ -154,8 +157,8 @@ impl TerminalTitleItem { /// 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`. + /// The activity indicator 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 { @@ -174,17 +177,25 @@ pub(crate) fn preview_line_for_title_items( items: &[TerminalTitleItem], preview_data: &StatusSurfacePreviewData, ) -> Option> { + if items.contains(&TerminalTitleItem::Spinner) { + let preview = build_action_required_title_text( + ACTION_REQUIRED_PREVIEW_PREFIX, + items.iter().copied(), + &[], + |item| { + item.preview_item() + .and_then(|preview_item| preview_data.value_for(preview_item)) + .map(str::to_owned) + }, + ); + return Some(Line::from(preview)); + } + let mut previous = None; let preview = items .iter() .copied() .fold(String::new(), |mut preview, item| { - if item == TerminalTitleItem::Spinner { - preview.push_str(item.separator_from_previous(previous)); - preview.push('⠋'); - previous = Some(item); - return preview; - } let Some(value) = item .preview_item() .and_then(|preview_item| preview_data.value_for(preview_item)) @@ -369,7 +380,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let selected = [ "project-name".to_string(), - "spinner".to_string(), + "activity".to_string(), "run-state".to_string(), "thread-title".to_string(), ]; @@ -384,7 +395,7 @@ mod tests { #[test] fn parse_terminal_title_items_preserves_order() { let items = parse_terminal_title_items( - ["project-name", "spinner", "run-state", "thread-title"].into_iter(), + ["project-name", "activity", "run-state", "thread-title"].into_iter(), ); assert_eq!( items, @@ -403,6 +414,19 @@ mod tests { assert_eq!(items, None); } + #[test] + fn activity_is_canonical_and_accepts_spinner_legacy_id() { + assert_eq!(TerminalTitleItem::Spinner.to_string(), "activity"); + assert_eq!( + "activity".parse::(), + Ok(TerminalTitleItem::Spinner) + ); + assert_eq!( + "spinner".parse::(), + Ok(TerminalTitleItem::Spinner) + ); + } + #[test] fn project_name_is_canonical_and_accepts_project_legacy_id() { assert_eq!(TerminalTitleItem::Project.to_string(), "project-name"); @@ -476,7 +500,7 @@ mod tests { "context-used", "five-hour-limit", "git-branch", - "spinner", + "activity", "current-dir", "project-name", "model", diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8513fe88164d..8fbb2fe832b9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1035,6 +1035,8 @@ pub(crate) struct ChatWidget { terminal_title_invalid_items_warned: Arc, // Last terminal title emitted, to avoid writing duplicate OSC updates. pub(crate) last_terminal_title: Option, + // Last visible "action required" state observed by the terminal-title renderer. + last_terminal_title_requires_action: bool, // Original terminal-title config captured when the setup UI opens. // // The outer `Option` tracks whether a setup session is active (`Some`) @@ -2110,13 +2112,13 @@ impl ChatWidget { .iter() .any(|item| item == "run-state" || item == "status") }); - let title_uses_spinner = self - .config - .tui_terminal_title - .as_ref() - .is_none_or(|items| items.iter().any(|item| item == "spinner")); + let title_uses_activity = self.config.tui_terminal_title.as_ref().is_none_or(|items| { + items + .iter() + .any(|item| item == "activity" || item == "spinner") + }); if title_uses_status - || (title_uses_spinner + || (title_uses_activity && self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing) { self.refresh_status_surfaces(); @@ -4917,7 +4919,12 @@ impl ChatWidget { self.schedule_hook_timer_if_needed(); self.bottom_pane.pre_draw_tick(); self.refresh_goal_status_indicator_for_time_tick(); - if self.should_animate_terminal_title_spinner() { + if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action { + self.refresh_terminal_title(); + } + if self.should_animate_terminal_title_spinner() + || self.should_animate_terminal_title_action_required() + { self.refresh_terminal_title(); } } @@ -5627,6 +5634,7 @@ impl ChatWidget { status_line_invalid_items_warned, terminal_title_invalid_items_warned, last_terminal_title: None, + last_terminal_title_requires_action: false, terminal_title_setup_original_items: None, terminal_title_animation_origin: Instant::now(), status_line_project_root_name_cache: None, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap index 8dbfffe2945a..9ab8659a374e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap @@ -13,7 +13,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] app-name Codex app name [ ] project-name Project name (falls back to current directory name) [ ] current-dir Current working directory - [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] activity Spinner while working, action-required message while blocked. [ ] run-state Compact session run-state text (Ready, Working, Thinking) thread title | feat/awesome-feature | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap index a6726dbe23f7..6ede93244b77 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap @@ -13,7 +13,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [x] task-progress Latest task progress from update_plan (omitted until available) [ ] app-name Codex app name [ ] current-dir Current working directory - [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] activity Spinner while working, action-required message while blocked. [ ] run-state Compact session run-state text (Ready, Working, Thinking) preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap index 2d995ce9aba8..842938ca0b61 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap @@ -12,7 +12,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [x] task-progress Latest task progress from update_plan (omitted until available) [ ] app-name Codex app name [ ] current-dir Current working directory - [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] activity Spinner while working, action-required message while blocked. [ ] run-state Compact session run-state text (Ready, Working, Thinking) [ ] git-branch Current Git branch (omitted when unavailable) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 87c21e564a63..6b590d7ea64a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -7,8 +7,8 @@ use super::*; use crate::status::format_tokens_compact; /// 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-name"]; +/// custom selection. Intentionally minimal: activity indicator + project name. +pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"]; /// Braille-pattern dot-spinner frames for the terminal title animation. pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = @@ -17,6 +17,13 @@ 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); +/// Time between action-required blink phases in the terminal title. +const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1); + +/// Prefix shown in the terminal title when the agent is blocked on user input. +const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX: &str = "[ ! ] Action Required"; +const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Required"; + /// Compact runtime states that can be rendered into the terminal title. /// /// This is intentionally smaller than the full status-header vocabulary. The @@ -177,9 +184,11 @@ impl ChatWidget { /// 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. + /// When the `activity` item is present in an animated running state, this also + /// schedules the next frame so the title animation keeps advancing. fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) { + self.last_terminal_title_requires_action = + self.terminal_title_shows_action_required_with_selections(selections); 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"); @@ -188,28 +197,11 @@ impl ChatWidget { } 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); + let title = self.terminal_title_text_for_selections(selections, now); + let animation_interval = self.terminal_title_animation_interval_with_selections(selections); if self.last_terminal_title == title { - if should_animate_spinner { - self.frame_requester - .schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL); + if let Some(interval) = animation_interval { + self.frame_requester.schedule_frame_in(interval); } return; } @@ -234,9 +226,8 @@ impl ChatWidget { } } - if should_animate_spinner { - self.frame_requester - .schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL); + if let Some(interval) = animation_interval { + self.frame_requester.schedule_frame_in(interval); } } @@ -263,6 +254,92 @@ impl ChatWidget { self.refresh_terminal_title_from_selections(&selections); } + fn terminal_title_requires_action(&self) -> bool { + self.bottom_pane.terminal_title_requires_action() + } + + pub(super) fn terminal_title_shows_action_required(&self) -> bool { + self.terminal_title_requires_action() && self.terminal_title_uses_activity() + } + + fn terminal_title_text_for_selections( + &mut self, + selections: &StatusSurfaceSelections, + now: Instant, + ) -> Option { + if self.terminal_title_shows_action_required_with_selections(selections) { + return Some(self.action_required_terminal_title_text(selections, 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 + }); + (!title.is_empty()).then_some(title) + } + + fn action_required_terminal_title_text( + &mut self, + selections: &StatusSurfaceSelections, + now: Instant, + ) -> String { + crate::bottom_pane::build_action_required_title_text( + self.action_required_terminal_title_prefix_at(now), + selections.terminal_title_items.iter().copied(), + &[TerminalTitleItem::Status], + |item| self.terminal_title_value_for_item(item, now), + ) + } + + fn action_required_terminal_title_prefix_at(&self, now: Instant) -> &'static str { + if !self.config.animations { + return TERMINAL_TITLE_ACTION_REQUIRED_PREFIX; + } + + let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin); + let phase = (elapsed.as_millis() / TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL.as_millis()) % 2; + if phase == 0 { + TERMINAL_TITLE_ACTION_REQUIRED_PREFIX + } else { + TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN + } + } + + fn terminal_title_shows_action_required_with_selections( + &self, + selections: &StatusSurfaceSelections, + ) -> bool { + self.terminal_title_requires_action() + && selections + .terminal_title_items + .contains(&TerminalTitleItem::Spinner) + } + + fn terminal_title_animation_interval_with_selections( + &self, + selections: &StatusSurfaceSelections, + ) -> Option { + if self.config.animations + && self.terminal_title_shows_action_required_with_selections(selections) + { + return Some(TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL); + } + + self.should_animate_terminal_title_spinner_with_selections(selections) + .then_some(TERMINAL_TITLE_SPINNER_INTERVAL) + } + pub(super) fn request_status_line_branch_refresh(&mut self) { let selections = self.status_surface_selections(); if !selections.uses_git_branch() { @@ -431,7 +508,7 @@ impl ChatWidget { } StatusLineItem::ProjectRoot => self.status_line_project_root_name(), StatusLineItem::GitBranch => self.status_line_branch.clone(), - StatusLineItem::Status => Some(self.terminal_title_status_text()), + StatusLineItem::Status => Some(self.run_state_status_text()), StatusLineItem::UsedTokens => { let usage = self.status_line_total_usage(); let total = usage.tokens_in_context_window(); @@ -505,7 +582,7 @@ impl ChatWidget { StatusSurfacePreviewItem::AppName => return Some("codex".to_string()), StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(), StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot, - StatusSurfacePreviewItem::Status => return Some(self.terminal_title_status_text()), + StatusSurfacePreviewItem::Status => return Some(self.run_state_status_text()), StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(), StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, @@ -526,7 +603,6 @@ impl ChatWidget { }; self.status_line_value_for_item(&status_line_item) } - /// Resolves one configured terminal-title item into a displayable segment. /// /// Returning `None` means "omit this segment for now" so callers can keep @@ -544,7 +620,7 @@ impl ChatWidget { /*max_chars*/ 32, )), TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now), - TerminalTitleItem::Status => Some(self.terminal_title_status_text()), + TerminalTitleItem::Status => Some(self.run_state_status_text()), TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| { let trimmed = name.trim(); if trimmed.is_empty() { @@ -612,11 +688,11 @@ impl ChatWidget { format!("{} {label}{fast_label}", self.model_display_name()) } - /// Computes the compact runtime status label used by the terminal title. + /// Computes the compact runtime status label used by word-based status items. /// /// 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 { + pub(super) fn run_state_status_text(&self) -> String { if self.mcp_startup_status.is_some() { return "Starting".to_string(); } @@ -659,14 +735,19 @@ impl ChatWidget { 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_uses_activity(&self) -> bool { + self.config.tui_terminal_title.as_ref().is_none_or(|items| { + items + .iter() + .any(|item| item == "activity" || item == "spinner") + }) } fn terminal_title_has_active_progress(&self) -> bool { + if self.terminal_title_shows_action_required() { + return false; + } + self.mcp_startup_status.is_some() || self.bottom_pane.is_task_running() || self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing @@ -674,10 +755,14 @@ impl ChatWidget { pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { self.config.animations - && self.terminal_title_uses_spinner() + && self.terminal_title_uses_activity() && self.terminal_title_has_active_progress() } + pub(super) fn should_animate_terminal_title_action_required(&self) -> bool { + self.config.animations && self.terminal_title_shows_action_required() + } + fn should_animate_terminal_title_spinner_with_selections( &self, selections: &StatusSurfaceSelections, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 376e0771e8c8..547c42509361 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -283,6 +283,7 @@ mod slash_commands; mod status_and_layout; mod status_command_tests; mod status_surface_previews; +mod terminal_title; pub(crate) use helpers::make_chatwidget_manual_with_sender; pub(crate) use helpers::set_chatgpt_auth; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index b94f60d20a58..901a532f6221 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -301,6 +301,7 @@ pub(super) async fn make_chatwidget_manual( status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), last_terminal_title: None, + last_terminal_title_requires_action: false, terminal_title_setup_original_items: None, terminal_title_animation_origin: Instant::now(), status_line_project_root_name_cache: None, diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs new file mode 100644 index 000000000000..724f7d26d756 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -0,0 +1,155 @@ +//! Terminal-title focused tests for live chatwidget status-surface behavior. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + chat.refresh_terminal_title(); + + let request = ExecApprovalRequestEvent { + call_id: "call-action-required".into(), + approval_id: Some("call-action-required".into()), + turn_id: "turn-action-required".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello".into()], + cwd: AbsolutePathBuf::current_dir().expect("current dir"), + reason: Some("need confirmation".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-action-required".into(), + msg: EventMsg::ExecApprovalRequest(request), + }); + + chat.pre_draw_tick(); + + assert_eq!( + chat.last_terminal_title, + Some("[ ! ] Action Required | project".to_string()) + ); + assert!(!chat.should_animate_terminal_title_spinner()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + chat.pre_draw_tick(); + + let title = chat + .last_terminal_title + .as_deref() + .expect("terminal title should be restored after approval"); + assert!(title.contains("project")); + assert!(!title.contains("Action Required")); + assert!(chat.should_animate_terminal_title_spinner()); +} + +#[tokio::test] +async fn terminal_title_action_required_respects_spinner_setting() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_terminal_title = Some(vec!["project".to_string()]); + chat.bottom_pane.set_task_running(/*running*/ true); + chat.refresh_terminal_title(); + + let request = ExecApprovalRequestEvent { + call_id: "call-no-spinner".into(), + approval_id: Some("call-no-spinner".into()), + turn_id: "turn-no-spinner".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello".into()], + cwd: AbsolutePathBuf::current_dir().expect("current dir"), + reason: Some("need confirmation".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-no-spinner".into(), + msg: EventMsg::ExecApprovalRequest(request), + }); + + chat.pre_draw_tick(); + + assert_eq!(chat.last_terminal_title, Some("project".to_string())); + assert!(!chat.should_animate_terminal_title_action_required()); +} + +#[tokio::test] +async fn terminal_title_action_required_blinks_when_animations_are_enabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500); + chat.refresh_terminal_title(); + + let request = ExecApprovalRequestEvent { + call_id: "call-blink".into(), + approval_id: Some("call-blink".into()), + turn_id: "turn-blink".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello".into()], + cwd: AbsolutePathBuf::current_dir().expect("current dir"), + reason: Some("need confirmation".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-blink".into(), + msg: EventMsg::ExecApprovalRequest(request), + }); + + chat.pre_draw_tick(); + + assert_eq!( + chat.last_terminal_title, + Some("[ . ] Action Required | project".to_string()) + ); + assert!(chat.should_animate_terminal_title_action_required()); +} + +#[tokio::test] +async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.animations = false; + chat.bottom_pane.set_task_running(/*running*/ true); + chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500); + chat.refresh_terminal_title(); + + assert_eq!(chat.last_terminal_title, Some("project".to_string())); + assert!(!chat.should_animate_terminal_title_spinner()); + + let request = ExecApprovalRequestEvent { + call_id: "call-no-animations".into(), + approval_id: Some("call-no-animations".into()), + turn_id: "turn-no-animations".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello".into()], + cwd: AbsolutePathBuf::current_dir().expect("current dir"), + reason: Some("need confirmation".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-no-animations".into(), + msg: EventMsg::ExecApprovalRequest(request), + }); + + chat.pre_draw_tick(); + + assert_eq!( + chat.last_terminal_title, + Some("[ ! ] Action Required | project".to_string()) + ); + assert!(!chat.should_animate_terminal_title_action_required()); +}