Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion codex-rs/config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>>,

Expand Down
2 changes: 1 addition & 1 deletion codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 3 additions & 1 deletion codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
25 changes: 25 additions & 0 deletions codex-rs/tui/src/bottom_pane/action_required_title.rs
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
canvrno-oai marked this conversation as resolved.
continue;
}
if let Some(value) = value_for(item) {
parts.push(value);
}
}
parts.join(" | ")
}
50 changes: 50 additions & 0 deletions codex-rs/tui/src/bottom_pane/app_link_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>();
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/tui/src/bottom_pane/approval_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
4 changes: 4 additions & 0 deletions codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ 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
[ ] current-dir Current working directory
[ ] 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.
50 changes: 37 additions & 13 deletions codex-rs/tui/src/bottom_pane/title_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")]
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -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(),
];
Expand All @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -476,7 +500,7 @@ mod tests {
"context-used",
"five-hour-limit",
"git-branch",
"spinner",
"activity",
"current-dir",
"project-name",
"model",
Expand Down
22 changes: 15 additions & 7 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading