Show action required in terminal title (#18372)

Implements #18162

This updates the TUI terminal title to show an explicit action-required
state when Codex is blocked on user approval or input. The terminal
title now uses the activity title item to cover both active work and
blocked-on-user states, while still accepting the legacy spinner config
value.

Changes
- Rename the terminal title item from `spinner` to `activity` while
preserving legacy config compatibility
- Show `[ ! ] Action Required `while approval or input overlays are
active, with a blinking `[ . ]` alternate state
- Suppress the normal working spinner while Codex is blocked on user
action
- Add targeted coverage for action-required title behavior and legacy
title-item parsing

Testing
- Trigger an approval or input modal and confirm the tab title
alternates between `[ ! ] Action Required` and `[ . ] Action Required`
- Disable the activity title item and confirm the action-required title
does not appear
- Resolve the prompt and confirm the title returns to the normal
spinning/idel state


https://github.com/user-attachments/assets/e9ecc530-a6be-4fd7-b9a6-d550a790eb2c
This commit is contained in:
canvrno-oai
2026-04-27 15:27:11 -07:00
committed by GitHub
Unverified
parent e903d000b0
commit e64c765673
20 changed files with 450 additions and 68 deletions
+3 -1
View File
@@ -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<Vec<String>>,
+1 -1
View File
@@ -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"
},
+3 -1
View File
@@ -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<Vec<String>>,
/// Syntax highlighting theme override (kebab-case name).
@@ -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<I, F>(
prefix: &str,
items: I,
excluded_items: &[TerminalTitleItem],
mut value_for: F,
) -> String
where
I: IntoIterator<Item = TerminalTitleItem>,
F: FnMut(TerminalTitleItem) -> Option<String>,
{
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(" | ")
}
@@ -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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
@@ -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 {
@@ -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
}
}
@@ -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()
{
+8
View File
@@ -45,6 +45,7 @@ use ratatui::text::Line;
use std::time::Duration;
use std::time::Instant;
mod action_required_title;
mod app_link_view;
mod approval_overlay;
mod mcp_server_elicitation;
@@ -53,6 +54,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;
@@ -1023,6 +1026,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()
@@ -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();
@@ -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.
+37 -13
View File
@@ -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 <spinner> Working` rather than `my-project | <spinner> | Working`.
/// The activity indicator gets a plain space on either side so it reads as
/// `my-project <activity> Working` rather than `my-project | <activity> | Working`.
/// All other adjacent items are joined with ` | `.
pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {
match previous {
@@ -174,17 +177,25 @@ pub(crate) fn preview_line_for_title_items(
items: &[TerminalTitleItem],
preview_data: &StatusSurfacePreviewData,
) -> Option<Line<'static>> {
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::<TerminalTitleItem>(),
Ok(TerminalTitleItem::Spinner)
);
assert_eq!(
"spinner".parse::<TerminalTitleItem>(),
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",
+15 -7
View File
@@ -1035,6 +1035,8 @@ pub(crate) struct ChatWidget {
terminal_title_invalid_items_warned: Arc<AtomicBool>,
// Last terminal title emitted, to avoid writing duplicate OSC updates.
pub(crate) last_terminal_title: Option<String>,
// 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,
@@ -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
@@ -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
@@ -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)
+125 -40
View File
@@ -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<String> {
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<Duration> {
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,
+1
View File
@@ -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;
@@ -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,
@@ -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());
}