diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 35dd0e109a..d0ed440f59 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -768,7 +768,7 @@ pub struct Tui { /// Ordered list of status line item identifiers. /// /// When set, the TUI renders the selected items as the status line. - /// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`. + /// When unset, the TUI defaults to: `model-with-reasoning`, `current-dir`, and `thread-name`. #[serde(default)] pub status_line: Option>, @@ -780,7 +780,7 @@ 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: `activity` and `project`. + /// When unset, the TUI defaults to: `activity`, `thread-name`, and `project-name`. /// The `activity` item spins while working and shows an action-required /// message when blocked on the user. #[serde(default)] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 32f2c83d16..2cfc7297ef 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4115,7 +4115,7 @@ }, "status_line": { "default": null, - "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line. When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.", + "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line. When unset, the TUI defaults to: `model-with-reasoning`, `current-dir`, and `thread-name`.", "items": { "type": "string" }, @@ -4135,7 +4135,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: `activity` and `project`. The `activity` item spins while working and shows an action-required message when blocked on the user.", + "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`, `thread-name`, and `project-name`. 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/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3274af22f9..6a6dda6bc0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -15,6 +15,7 @@ use crate::app_event::PluginLocation; use crate::app_event::PluginRemoteSectionError; use crate::app_event::RateLimitRefreshOrigin; use crate::app_event::RunningTaskExitAction; +use crate::app_event::ThreadTitleDestination; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; @@ -615,6 +616,8 @@ pub(crate) struct App { thread_event_channels: HashMap, temporary_structured_requests: HashMap>, + /// Track title generation across thread switches and deduplicate automatic requests. + pending_thread_titles: HashSet<(ThreadId, ThreadTitleDestination)>, thread_event_listener_tasks: HashMap>, agent_navigation: AgentNavigationState, agents_overview: agents_overview::AgentsOverviewState, @@ -985,6 +988,7 @@ impl App { } fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui, screen_size: Size) -> Result { + self.sync_thread_title_progress(); let dashboard_visible = self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 46b7b400cb..b44fcedb6a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2582,16 +2582,20 @@ impl App { self.temporary_structured_requests .remove(&temporary_thread_id); + self.finish_thread_title_generation(thread_id, destination); match destination { - ThreadTitleDestination::Automatic { expected_title } => { + ThreadTitleDestination::Automatic => { if let Ok(response) = result && let Some(title) = super::thread_title::parse_thread_title(&response) - && self.chat_widget.thread_id() == Some(thread_id) - && self.chat_widget.thread_name().as_deref() - == Some(expected_title.as_str()) + && let Ok(thread) = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + && thread.name.is_none() { match app_server.thread_set_name(thread_id, title.clone()).await { - Ok(()) => self.chat_widget.expect_automatic_thread_name(title), + Ok(()) => self + .chat_widget + .on_thread_name_updated(thread_id, Some(title)), Err(error) => { tracing::debug!(%error, "failed to apply generated thread title"); } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index bf44586ce2..7fee661b1e 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -265,6 +265,8 @@ impl App { self.pending_plugin_enabled_writes.clear(); self.pending_hook_enabled_writes.clear(); self.temporary_structured_requests.clear(); + self.pending_thread_titles.clear(); + self.sync_thread_title_progress(); self.agents_overview.dispatched_requests.clear(); self.agents_overview.request_id = None; self.agents_overview.refresh_pending = false; diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 0dacd3b588..edbcf2d590 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -552,6 +552,7 @@ See the Codex keymap documentation for supported actions and examples." windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), temporary_structured_requests: HashMap::new(), + pending_thread_titles: HashSet::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), agents_overview: Default::default(), @@ -910,6 +911,7 @@ See the Codex keymap documentation for supported actions and examples." } } => { app.chat_widget.refresh_goal_status_indicator_for_time_tick(); + app.chat_widget.refresh_thread_title_progress_for_time_tick(); app.chat_widget.refresh_terminal_title(); AppRunControl::Continue } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index ec93917e4f..4b338b9faa 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -64,6 +64,7 @@ pub(super) async fn make_test_app() -> App { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), temporary_structured_requests: HashMap::new(), + pending_thread_titles: HashSet::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), agents_overview: Default::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e4225417a4..be41bbfd28 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5513,6 +5513,7 @@ async fn make_test_app() -> App { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), temporary_structured_requests: HashMap::new(), + pending_thread_titles: HashSet::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), agents_overview: Default::default(), @@ -5598,6 +5599,7 @@ pub(super) async fn make_test_app_with_channels() -> ( windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), temporary_structured_requests: HashMap::new(), + pending_thread_titles: HashSet::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), agents_overview: Default::default(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c2b47d43a5..73dce0c45a 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1883,37 +1883,16 @@ impl App { let had_active_view = self.chat_widget.has_active_view(); self.handle_thread_event_now_recovering_file_changes(event) .await; - if let Some(user_message) = automatic_title_user_message { - let expected_title = user_message - .split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(super::thread_title::THREAD_TITLE_MAX_CHARS) - .collect::(); - - if !expected_title.is_empty() - && let Some(thread_id) = self.active_thread_id - { - match app_server - .thread_set_name(thread_id, expected_title.clone()) - .await - { - Ok(()) => { - self.chat_widget - .expect_automatic_thread_name(expected_title.clone()); - self.generate_thread_title( - app_server, - thread_id, - ThreadTitleDestination::Automatic { expected_title }, - super::thread_title::thread_title_prompt(&user_message), - ); - } - Err(error) => { - tracing::debug!(%error, "failed to set provisional thread title"); - } - } - } + if let Some(user_message) = automatic_title_user_message + && !user_message.trim().is_empty() + && let Some(thread_id) = self.active_thread_id + { + self.generate_thread_title( + app_server, + thread_id, + ThreadTitleDestination::Automatic, + super::thread_title::thread_title_prompt(&user_message), + ); } if !had_active_view && self.chat_widget.has_active_view() diff --git a/codex-rs/tui/src/app/thread_title.rs b/codex-rs/tui/src/app/thread_title.rs index 72abb5810b..1a699e8fb4 100644 --- a/codex-rs/tui/src/app/thread_title.rs +++ b/codex-rs/tui/src/app/thread_title.rs @@ -1,4 +1,7 @@ //! Structured-output schema and normalization for generated TUI thread titles. +//! +//! Automatic titles are persisted only after generation, including for inactive threads; +//! the originating thread's saved name takes precedence over an automatic result. use super::App; use super::thread_events::ThreadBufferedEvent; @@ -31,6 +34,25 @@ struct GeneratedThreadTitle { } impl App { + pub(super) fn sync_thread_title_progress(&mut self) { + let pending = self.chat_widget.thread_id().is_some_and(|thread_id| { + self.pending_thread_titles + .iter() + .any(|(id, _)| *id == thread_id) + }); + self.chat_widget + .set_thread_title_generation_pending(pending); + } + + pub(super) fn finish_thread_title_generation( + &mut self, + thread_id: ThreadId, + destination: ThreadTitleDestination, + ) { + self.pending_thread_titles.remove(&(thread_id, destination)); + self.sync_thread_title_progress(); + } + /// Start a hidden title-generation thread without blocking the UI loop. pub(super) fn generate_thread_title( &mut self, @@ -39,6 +61,10 @@ impl App { destination: ThreadTitleDestination, prompt: String, ) { + if !self.pending_thread_titles.insert((thread_id, destination)) { + return; + } + self.sync_thread_title_progress(); let request_handle = app_server.request_handle(); let model = if self.chat_widget.config_ref().model_provider_id == "openai" && self.chat_widget.has_chatgpt_account() @@ -96,6 +122,7 @@ impl App { Ok(thread_id) => thread_id, Err(error) => { tracing::debug!(%error, "failed to start title-generation thread"); + self.finish_thread_title_generation(thread_id, destination); if let ThreadTitleDestination::RenameSuggestion { request_id } = destination { self.chat_widget.apply_thread_name_suggestion( thread_id, request_id, /*suggestion*/ None, @@ -106,6 +133,7 @@ impl App { }; let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else { + self.finish_thread_title_generation(thread_id, destination); if let ThreadTitleDestination::RenameSuggestion { request_id } = destination { self.chat_widget .apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None); diff --git a/codex-rs/tui/src/app/thread_title_tests.rs b/codex-rs/tui/src/app/thread_title_tests.rs index 6044fd82a1..c5ee38daa5 100644 --- a/codex-rs/tui/src/app/thread_title_tests.rs +++ b/codex-rs/tui/src/app/thread_title_tests.rs @@ -7,12 +7,16 @@ use super::recent_conversation_thread_title_prompt; use super::thread_title_prompt; use crate::app::session_lifecycle::ThreadAttachPresentation; use crate::app::test_support::make_test_app; -use crate::app_command::AppCommand; +use crate::app::thread_events::ThreadBufferedEvent; use crate::app_event::AppEvent; +use crate::app_event::ThreadTitleDestination; use crate::app_event_sender::AppEventSender; +use crate::app_server_session::ResumeModelSettings; use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::test_support::PathBufExt; use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -82,39 +86,97 @@ fn bounds_the_entire_title_prompt_for_dense_unicode() { } #[tokio::test] -async fn manual_rename_invalidates_pending_automatic_title_before_notification() --> color_eyre::Result<()> { - let mut app = make_test_app().await; - let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; - let started = app_server.start_thread(&app.config).await?; - let thread_id = started.session.thread_id; - app.enqueue_primary_thread_session(started.session, started.turns) +async fn automatic_thread_title_respects_origin_metadata_after_switching() -> color_eyre::Result<()> +{ + for (switch_threads, manual_name) in [ + (true, None), + (true, Some("Manual title")), + (false, Some("Manual title")), + ] { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + let user_message = serde_json::from_str( + r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"Fix the login timeout"}]}"#, + )?; + app_server + .thread_inject_items(thread_id, vec![user_message]) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.pending_thread_titles + .insert((thread_id, ThreadTitleDestination::Automatic)); + + let mut tui = crate::tui::test_support::make_test_tui()?; + if switch_threads { + let second = app_server.start_thread(&app.config).await?; + let second_id = second.session.thread_id; + app.ensure_thread_channel(second_id) + .store + .lock() + .await + .set_session(second.session, second.turns); + app.select_agent_thread(&mut tui, &mut app_server, second_id) + .await?; + } + if let Some(name) = manual_name { + // The saved name must win even before its notification reaches the widget. + app_server + .thread_set_name(thread_id, name.to_string()) + .await?; + } + let displayed_thread = (app.chat_widget.thread_id(), app.chat_widget.thread_name()); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::GeneratedThreadTitle { + thread_id, + temporary_thread_id: codex_protocol::ThreadId::new(), + destination: ThreadTitleDestination::Automatic, + result: Ok(r#"{"title":"Generated title"}"#.to_string()), + }, + ) .await?; - app_server - .thread_set_name(thread_id, "Provisional title".to_string()) - .await?; - app.chat_widget - .expect_automatic_thread_name("Provisional title".to_string()); - - app.submit_thread_op( - &mut app_server, - thread_id, - AppCommand::set_thread_name("Manual title".to_string()), - ) - .await?; - - assert_eq!( - app.chat_widget.thread_name(), - Some("Manual title".to_string()) - ); - - app_server.shutdown().await?; + let expected_name = Some(manual_name.unwrap_or("Generated title").to_string()); + assert_eq!( + app_server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .name, + expected_name + ); + assert_eq!( + (app.chat_widget.thread_id(), app.chat_widget.thread_name()), + displayed_thread + ); + assert!(app.pending_thread_titles.is_empty()); + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + ResumeModelSettings::PreserveExistingThread, + ) + .await?; + assert_eq!(resumed.session.thread_name, expected_name); + app_server.shutdown().await?; + } Ok(()) } #[tokio::test] async fn slash_rename_generates_editable_title_through_embedded_app_server() -> color_eyre::Result<()> { + check_thread_title_generation(/*automatic*/ false).await +} + +#[tokio::test] +async fn automatic_thread_title_generates_without_a_provisional_name() -> color_eyre::Result<()> { + check_thread_title_generation(/*automatic*/ true).await +} + +async fn check_thread_title_generation(automatic: bool) -> color_eyre::Result<()> { let server = wiremock::MockServer::start().await; let response = responses::mount_sse_once( &server, @@ -186,10 +248,37 @@ async fn slash_rename_generates_editable_title_through_embedded_app_server() }); while event_rx.try_recv().is_ok() {} - app.chat_widget.apply_external_edit("/rename".to_string()); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Generating")); + if automatic { + for _ in 0..2 { + app.handle_active_thread_event( + &mut tui, + &mut app_server, + ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "existing-turn".to_string(), + item: title_user_message("user-message", "Fix the login timeout"), + completed_at_ms: 0, + }, + ))), + ) + .await?; + } + assert_eq!(app.chat_widget.thread_name(), None); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + assert_eq!( + app_server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .name, + None + ); + } else { + app.chat_widget.apply_external_edit("/rename".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Generating")); + } enum TitleDriveEvent { Ui(Box), @@ -220,10 +309,34 @@ async fn slash_rename_generates_editable_title_through_embedded_app_server() } } - let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80); - assert!(popup.contains("Fix login timeout")); - assert!(!popup.contains("Generating a title suggestion")); + if automatic { + assert_eq!( + app.chat_widget.thread_name(), + Some("Fix login timeout".to_string()) + ); + assert_eq!( + app_server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .name, + Some("Fix login timeout".to_string()) + ); + assert!(app.pending_thread_titles.is_empty()); + } else { + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert!(popup.contains("Fix login timeout")); + assert!(!popup.contains("Generating a title suggestion")); + assert_eq!( + app_server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .name, + None + ); + } assert!(app.temporary_structured_requests.is_empty()); + assert!(app.pending_thread_titles.is_empty()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); let request = response.single_request(); assert!( @@ -482,3 +595,83 @@ fn title_agent_message(id: &str, text: &str, phase: Option) -> Thr questions: None, } } + +#[tokio::test] +async fn thread_title_progress_clears_failed_requests_and_follows_thread_switches() +-> color_eyre::Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + let suggestion = ThreadTitleDestination::RenameSuggestion { + request_id: uuid::Uuid::new_v4(), + }; + app.pending_thread_titles.extend([ + (thread_id, ThreadTitleDestination::Automatic), + (thread_id, suggestion), + ]); + app.sync_thread_title_progress(); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + + app.on_thread_title_started( + &app_server, + thread_id, + ThreadTitleDestination::Automatic, + "prompt".to_string(), + /*effort*/ None, + Err("startup failed".to_string()), + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + app.on_thread_title_started( + &app_server, + thread_id, + suggestion, + "prompt".to_string(), + /*effort*/ None, + Ok("invalid-thread-id".to_string()), + ); + assert!(app.pending_thread_titles.is_empty()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + + let mut tui = crate::tui::test_support::make_test_tui()?; + app.pending_thread_titles + .insert((thread_id, ThreadTitleDestination::Automatic)); + let second = app_server.start_thread(&app.config).await?; + let second_id = second.session.thread_id; + app.ensure_thread_channel(second_id) + .store + .lock() + .await + .set_session(second.session, second.turns); + app.select_agent_thread(&mut tui, &mut app_server, second_id) + .await?; + app.render_chat_widget_frame( + &mut tui, + ratatui::layout::Size::new(/*width*/ 120, /*height*/ 30), + )?; + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + app.select_agent_thread(&mut tui, &mut app_server, thread_id) + .await?; + app.render_chat_widget_frame( + &mut tui, + ratatui::layout::Size::new(/*width*/ 120, /*height*/ 30), + )?; + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::GeneratedThreadTitle { + thread_id, + temporary_thread_id: codex_protocol::ThreadId::new(), + destination: ThreadTitleDestination::Automatic, + result: Err("generation failed".to_string()), + }, + ) + .await?; + assert!(app.pending_thread_titles.is_empty()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("renaming...")); + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 66d8f934fc..7c5bed7b19 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -201,10 +201,10 @@ pub(crate) enum TranscriptExportDestination { } /// Deliver a generated title to its originating automatic rename or editable prompt. -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum ThreadTitleDestination { - /// Replace the provisional name only if the user has not renamed the thread. - Automatic { expected_title: String }, + /// Name the thread only if the user has not already named it. + Automatic, /// Prefill only the still-active rename prompt with the matching request ID. RenameSuggestion { request_id: Uuid }, } 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 be5cecebf1..dfdcbdf887 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 @@ -8,14 +8,14 @@ expression: "render_lines(&view, 84)" Type to search > -› [x] project-name Project name (falls back to current directory name) - [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, or thread identifier when unnamed - [ ] 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… +› [x] project-name Project name (falls back to current directory name) + [x] activity Spinner while working, action-required message while blocked. + [x] run-state Compact session run-state text (Ready, Working, Thinking) + [x] thread-title Current thread title, or thread identifier when unnamed + [ ] app-name Codex app name + [ ] current-dir Current working directory + [ ] thread-name Current thread name (omitted when unnamed) + [ ] git-branch Current Git branch (omitted when unavailable) [ ! ] Action Required | my-project | Working | thread title Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index a5f728ed82..64562d146c 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -144,7 +144,10 @@ pub(crate) enum StatusLineItem { /// Whether raw scrollback mode is currently active. RawOutput, - /// Current thread title (if set by user). + /// Current thread name, omitted when unnamed. + ThreadName, + + /// Current thread title, falling back to its identifier when unnamed. ThreadTitle, /// Current workspace notification headline. @@ -202,6 +205,7 @@ impl StatusLineItem { StatusLineItem::SessionId => "Current thread identifier (omitted until thread starts)", StatusLineItem::FastMode => "Whether Fast mode is currently active", StatusLineItem::RawOutput => "Whether raw scrollback mode is active", + StatusLineItem::ThreadName => "Current thread name (omitted when unnamed)", StatusLineItem::ThreadTitle => { "Current thread title, or thread identifier when unnamed" } @@ -242,6 +246,7 @@ impl StatusLineItem { StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId, StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, + StatusLineItem::ThreadName => StatusSurfacePreviewItem::ThreadName, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 4783f5523b..bac7af8c5b 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -53,7 +53,9 @@ impl StatusLineAccent { StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, - StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, + StatusLineItem::ThreadName + | StatusLineItem::ThreadTitle + | StatusLineItem::WorkspaceHeadline => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index c0ce0f6c47..93b7ee3bd5 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -13,6 +13,7 @@ pub(crate) enum StatusSurfacePreviewItem { CurrentDir, Hostname, Status, + ThreadName, ThreadTitle, GitBranch, PullRequestNumber, @@ -49,6 +50,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::CurrentDir => "~/my-project/subdir", StatusSurfacePreviewItem::Hostname => "my-host", StatusSurfacePreviewItem::Status => "Working", + StatusSurfacePreviewItem::ThreadName => "thread name", StatusSurfacePreviewItem::ThreadTitle => "thread title", StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", StatusSurfacePreviewItem::PullRequestNumber => "PR #123", @@ -85,6 +87,7 @@ impl StatusSurfacePreviewItem { Self::CurrentDir, Self::Hostname, Self::Status, + Self::ThreadName, Self::ThreadTitle, Self::GitBranch, Self::PullRequestNumber, diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index 146d684791..a0815b4607 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -50,6 +50,8 @@ pub(crate) enum TerminalTitleItem { /// Compact runtime run-state text. #[strum(to_string = "run-state", serialize = "status")] Status, + /// Current thread name, omitted when unnamed. + ThreadName, /// Current thread title (if available). #[strum(to_string = "thread-title", serialize = "thread")] Thread, @@ -104,6 +106,7 @@ impl TerminalTitleItem { TerminalTitleItem::Status => { "Compact session run-state text (Ready, Working, Thinking)" } + TerminalTitleItem::ThreadName => "Current thread name (omitted when unnamed)", TerminalTitleItem::Thread => "Current thread title, or thread identifier when unnamed", TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)", TerminalTitleItem::ContextRemaining => { @@ -148,6 +151,7 @@ impl TerminalTitleItem { TerminalTitleItem::CurrentDir => Some(StatusSurfacePreviewItem::CurrentDir), TerminalTitleItem::Spinner => None, TerminalTitleItem::Status => Some(StatusSurfacePreviewItem::Status), + TerminalTitleItem::ThreadName => Some(StatusSurfacePreviewItem::ThreadName), TerminalTitleItem::Thread => Some(StatusSurfacePreviewItem::ThreadTitle), TerminalTitleItem::GitBranch => Some(StatusSurfacePreviewItem::GitBranch), TerminalTitleItem::ContextRemaining => Some(StatusSurfacePreviewItem::ContextRemaining), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 67a168a7f0..85eaa61c1d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -158,7 +158,6 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::resume_hint; use codex_utils_path_uri::PathUri; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -434,6 +433,7 @@ mod status_controls; mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; +mod thread_title_status; mod thread_usage; pub(crate) use self::thread_usage::ThreadUsageOutcome; mod tokens; @@ -504,7 +504,7 @@ const ASK_FOR_APPROVAL_LABEL: &str = "Ask for approval"; const APPROVE_FOR_ME_LABEL: &str = "Approve for me"; const AUTO_REVIEW_DESCRIPTION: &str = "Only ask for actions detected as potentially unsafe."; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; -const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"]; +const DEFAULT_STATUS_LINE_ITEMS: [&str; 3] = ["model-with-reasoning", "current-dir", "thread-name"]; /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { @@ -691,7 +691,6 @@ pub(crate) struct ChatWidget { pet_image_support_override: Option, thread_id: Option, thread_name: Option, - pending_automatic_thread_names: HashSet, thread_rename_block_message: Option, active_side_conversation: bool, blocks_direct_input: bool, @@ -1578,18 +1577,6 @@ impl ChatWidget { self.add_error_message(format!("{feature}: {TUI_STUB_MESSAGE}")); } - fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { - let mut line = vec![ - "• ".into(), - "Session renamed to ".into(), - name.to_string().cyan(), - ]; - if let Some(hint) = resume_hint(Some(name), thread_id) { - line.extend([". To resume this session run ".into(), hint.cyan()]); - } - PlainHistoryCell::new(vec![line.into()]) - } - /// Begin the asynchronous MCP inventory flow: show a loading spinner and /// request the app-server fetch via `AppEvent::FetchMcpInventory`. /// diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index d729ef026f..d6852cbbd0 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -213,7 +213,6 @@ impl ChatWidget { pet_image_support_override: None, thread_id: None, thread_name: None, - pending_automatic_thread_names: HashSet::new(), thread_rename_block_message: None, active_side_conversation: false, blocks_direct_input: false, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 89e9b988f1..8ff0c58383 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -30,7 +30,6 @@ impl ChatWidget { self.backend_banner_notice_model = None; self.automatic_model_switch_state = backend_banners::AutomaticModelSwitchState::default(); - self.pending_automatic_thread_names.clear(); self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); self.clear_thread_usage_state(); } @@ -265,13 +264,7 @@ impl ChatWidget { ))); } - /// Apply a persisted automatic name immediately and suppress its confirmation. - pub(crate) fn expect_automatic_thread_name(&mut self, name: String) { - self.thread_name = Some(name.clone()); - self.pending_automatic_thread_names.insert(name); - } - - /// Make a confirmed manual rename visible before its queued server notification arrives. + /// Update status surfaces before a confirmed manual rename's server notification arrives. pub(crate) fn expect_manual_thread_name(&mut self, thread_id: ThreadId, name: String) { if self.thread_id == Some(thread_id) { self.thread_name = Some(name); @@ -280,23 +273,13 @@ impl ChatWidget { } } - pub(super) fn on_thread_name_updated( + /// Update name metadata silently, including late and replayed notifications. + pub(crate) fn on_thread_name_updated( &mut self, thread_id: ThreadId, thread_name: Option, ) { if self.thread_id == Some(thread_id) { - let automatic = thread_name - .as_ref() - .is_some_and(|name| self.pending_automatic_thread_names.remove(name)); - - if let Some(name) = thread_name.as_deref() - && !automatic - { - let cell = Self::rename_confirmation_cell(name, self.thread_id); - self.add_boxed_history(Box::new(cell)); - } - self.thread_name = thread_name; self.refresh_status_surfaces(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__default_footer_generating_thread_title.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__default_footer_generating_thread_title.snap new file mode 100644 index 0000000000..b4a338a29c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__default_footer_generating_thread_title.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/terminal_title.rs +expression: "render_bottom_popup(&chat, 100)" +--- +› Ask Codex to do anything + + gpt-5.6-sol default · /tmp/project · renaming... ⠋ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manual_thread_name_status_surfaces.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manual_thread_name_status_surfaces.snap new file mode 100644 index 0000000000..0b59322dac --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manual_thread_name_status_surfaces.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: "format!(\"transcript:\\n{transcript}\\nfooter:\\n{footer}\\ntitle: {title}\")" +--- +transcript: + +footer: +› Ask Codex to do anything + + gpt-5.6-sol default · /tmp/project · review-fix +title: review-fix | project 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 b04e07bd9b..b4aec24fc3 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 @@ -14,7 +14,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] current-dir Current working directory [ ] 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) + [ ] thread-name Current thread name (omitted when unnamed) project | Mixed preview thread | Tasks 0/0 Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap index c821ddce93..f177011f07 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -14,7 +14,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] current-dir Current working directory [ ] activity Spinner while working, action-required message while blocked. [ ] run-state Compact session run-state text (Ready, Working, Thinking) - [ ] thread-title Current thread title, or thread identifier when unnamed + [ ] thread-name Current thread name (omitted when unnamed) monthly 65% left | weekly 50% left Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap index fe2b4d94c3..7ad1582ee4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap @@ -14,7 +14,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] current-dir Current working directory [ ] activity Spinner while working, action-required message while blocked. [ ] run-state Compact session run-state text (Ready, Working, Thinking) - [ ] thread-title Current thread title, or thread identifier when unnamed + [ ] thread-name Current thread name (omitted when unnamed) 5.2 credits | ~$1.82 Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap deleted file mode 100644 index f6f827fe53..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests/app_server.rs -expression: rendered ---- -• Session renamed to review-fix. To resume this session run codex resume, then select review-fix (123e4567-e89b-12d3-a456-426614174000) diff --git a/codex-rs/tui/src/chatwidget/status_state.rs b/codex-rs/tui/src/chatwidget/status_state.rs index ec78b3f2e6..bd42599424 100644 --- a/codex-rs/tui/src/chatwidget/status_state.rs +++ b/codex-rs/tui/src/chatwidget/status_state.rs @@ -116,6 +116,7 @@ pub(super) struct StatusState { pub(super) terminal_title_status_kind: TerminalTitleStatusKind, pub(super) retry_status_header: Option, pub(super) pending_status_indicator_restore: bool, + pub(super) thread_title_generation_pending: bool, } impl Default for StatusState { @@ -127,6 +128,7 @@ impl Default for StatusState { terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, + thread_title_generation_pending: false, } } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 3cf584cffb..17f437c488 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -24,8 +24,9 @@ use codex_utils_sandbox_summary::summarize_permission_profile; use super::status_state::TerminalTitleStatusKind; /// Items shown in the terminal title when the user has not configured a -/// custom selection. Intentionally minimal: activity indicator + project name. -pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"]; +/// custom selection. Unnamed threads omit the name until generation starts. +pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 3] = + ["activity", "thread-name", "project-name"]; /// Braille-pattern dot-spinner frames for the terminal title animation. pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = @@ -388,6 +389,26 @@ impl ChatWidget { &self, selections: &StatusSurfaceSelections, ) -> Option { + if self.local_settings.tui.animations + && self.status_state.thread_title_generation_pending + && (selections.status_line_items.iter().any(|item| { + matches!( + item, + StatusLineItem::ThreadName + | StatusLineItem::ThreadTitle + | StatusLineItem::SessionId + ) + }) || selections.terminal_title_items.iter().any(|item| { + matches!( + item, + TerminalTitleItem::ThreadName + | TerminalTitleItem::Thread + | TerminalTitleItem::SessionId + ) + })) + { + return Some(TERMINAL_TITLE_SPINNER_INTERVAL); + } if self.local_settings.tui.animations && self.terminal_title_shows_action_required_with_selections(selections) { @@ -677,7 +698,7 @@ impl ChatWidget { /// 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 { + pub(super) fn status_line_value(&mut self, item: StatusLineItem) -> Option { match item { StatusLineItem::ModelName => Some(self.model_display_name().to_string()), StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()), @@ -784,17 +805,14 @@ impl ChatWidget { } }), StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()), - StatusLineItem::ThreadTitle => self.thread_name.as_ref().map_or_else( - || self.thread_id.map(|id| id.to_string()), - |name| { - let trimmed = name.trim(); - if trimmed.is_empty() { - self.thread_id.map(|id| id.to_string()) - } else { - Some(trimmed.to_string()) - } - }, - ), + StatusLineItem::ThreadName => { + self.thread_name.as_deref().and_then(normalize_thread_name) + } + StatusLineItem::ThreadTitle => self + .thread_name + .as_deref() + .and_then(normalize_thread_name) + .or_else(|| self.thread_id.map(|id| id.to_string())), StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } @@ -819,6 +837,7 @@ impl ChatWidget { StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(), StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, StatusSurfacePreviewItem::Hostname => StatusLineItem::Hostname, + StatusSurfacePreviewItem::ThreadName => StatusLineItem::ThreadName, StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber, @@ -864,9 +883,17 @@ impl ChatWidget { )), TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now), TerminalTitleItem::Status => Some(self.run_state_status_text()), - TerminalTitleItem::Thread => self - .status_line_value_for_item(StatusLineItem::ThreadTitle) - .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 48)), + TerminalTitleItem::Thread | TerminalTitleItem::ThreadName => { + let item = if item == TerminalTitleItem::ThreadName { + StatusLineItem::ThreadName + } else { + StatusLineItem::ThreadTitle + }; + let value = self + .status_line_value(item) + .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 48)); + self.with_thread_title_progress(value, now) + } TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| { Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32) }), @@ -900,9 +927,12 @@ impl ChatWidget { TerminalTitleItem::EstimatedThreadCost => self .status_line_value_for_item(StatusLineItem::EstimatedThreadCost) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), - TerminalTitleItem::SessionId => self - .status_line_value_for_item(StatusLineItem::SessionId) - .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), + TerminalTitleItem::SessionId => { + let value = self + .status_line_value(StatusLineItem::SessionId) + .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)); + self.with_thread_title_progress(value, now) + } TerminalTitleItem::FastMode => self .status_line_value_for_item(StatusLineItem::FastMode) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), @@ -982,7 +1012,7 @@ impl ChatWidget { Some(self.terminal_title_spinner_frame_at(now).to_string()) } - fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str { + pub(super) 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; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 5f46c12d90..dca8e84efe 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1735,12 +1735,14 @@ async fn live_app_server_invalid_thread_name_update_is_ignored() { } #[tokio::test] -async fn live_app_server_thread_name_update_shows_resume_hint() { +async fn live_app_server_manual_thread_name_updates_status_surfaces() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); chat.thread_id = Some(thread_id); + chat.expect_manual_thread_name(thread_id, "review-fix".to_string()); + chat.handle_server_notification( ServerNotification::ThreadNameUpdated( codex_app_server_protocol::ThreadNameUpdatedNotification { @@ -1753,46 +1755,53 @@ async fn live_app_server_thread_name_update_shows_resume_hint() { assert_eq!(chat.thread_name, Some("review-fix".to_string())); let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1); - let rendered = lines_to_single_string(&cells[0]); - assert_chatwidget_snapshot!("thread_name_update_resume_hint", rendered); + let transcript = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + let footer = render_bottom_popup(&chat, /*width*/ 80); + let title = chat.last_terminal_title.as_deref().unwrap(); + assert_chatwidget_snapshot!( + "manual_thread_name_status_surfaces", + normalize_snapshot_paths(format!( + "transcript:\n{transcript}\nfooter:\n{footer}\ntitle: {title}" + )) + ); } #[tokio::test] async fn live_app_server_automatic_thread_name_update_is_silent() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let thread_id = ThreadId::new(); - chat.thread_id = Some(thread_id); - for title in ["Provisional title", "Generated title"] { - chat.expect_automatic_thread_name(title.to_string()); - assert_eq!(chat.thread_name(), Some(title.to_string())); + for replay_kind in [ + None, + Some(ReplayKind::ThreadSnapshot), + Some(ReplayKind::ResumeInitialMessages), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + let mut session = configured_thread_session(thread_id); + session.thread_name = Some("Generated title".to_string()); + chat.handle_thread_session(session); + drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::ThreadNameUpdated( codex_app_server_protocol::ThreadNameUpdatedNotification { thread_id: thread_id.to_string(), - thread_name: Some(title.to_string()), + thread_name: Some("Generated title".to_string()), }, ), - /*replay_kind*/ None, + replay_kind, ); - assert_eq!(chat.thread_name, Some(title.to_string())); - assert!(drain_insert_history(&mut rx).is_empty()); + assert_eq!(chat.thread_name(), Some("Generated title".to_string())); + let transcript = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + insta::allow_duplicates! { + assert_snapshot!(transcript, @""); + } } - - chat.handle_server_notification( - ServerNotification::ThreadNameUpdated( - codex_app_server_protocol::ThreadNameUpdatedNotification { - thread_id: thread_id.to_string(), - thread_name: Some("Manual title".to_string()), - }, - ), - /*replay_kind*/ None, - ); - - assert_eq!(chat.thread_name, Some("Manual title".to_string())); - assert_eq!(drain_insert_history(&mut rx).len(), 1); } #[tokio::test] @@ -1800,7 +1809,6 @@ async fn live_app_server_manual_thread_name_is_visible_before_notification() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); - chat.expect_automatic_thread_name("Provisional title".to_string()); chat.expect_manual_thread_name(thread_id, "Manual title".to_string()); @@ -1818,7 +1826,7 @@ async fn live_app_server_manual_thread_name_is_visible_before_notification() { ); assert_eq!(chat.thread_name(), Some("Manual title".to_string())); - assert_eq!(drain_insert_history(&mut rx).len(), 1); + assert!(drain_insert_history(&mut rx).is_empty()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__terminal_title__thread_title_generation_status_surfaces.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__terminal_title__thread_title_generation_status_surfaces.snap new file mode 100644 index 0000000000..e1b2adba20 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__terminal_title__thread_title_generation_status_surfaces.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/chatwidget/tests/terminal_title.rs +expression: "rendered.join(\"\\n\\n\")" +--- +name=None, pending=false +status: | 550e8400-e29b-41d4-a716-446655440000 | 550e8400-e29b-41d4-a716-446655440000 +title: | 550e8400-e29b-41d4-a716-446655440000 | 550e8400-e29b-41d4-a716-44665... +default title: project + +name=None, pending=true +status: renaming... ⠋ | 550e8400-e29b-41d4-a716-446655440000 ⠋ | 550e8400-e29b-41d4-a716-446655440000 ⠋ +title: renaming... ⠋ | 550e8400-e29b-41d4-a716-446655440000 ⠋ | 550e8400-e29b-41d4-a716-44665... ⠋ +default title: renaming... ⠋ | project + +name=Some("Fix login timeout"), pending=true +status: Fix login timeout ⠋ | Fix login timeout ⠋ | 550e8400-e29b-41d4-a716-446655440000 ⠋ +title: Fix login timeout ⠋ | Fix login timeout ⠋ | 550e8400-e29b-41d4-a716-44665... ⠋ +default title: Fix login timeout ⠋ | project + +name=Some("Fix login timeout"), pending=false +status: Fix login timeout | Fix login timeout | 550e8400-e29b-41d4-a716-446655440000 +title: Fix login timeout | Fix login timeout | 550e8400-e29b-41d4-a716-44665... +default title: Fix login timeout | project diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 69dc5379e5..d310031d48 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -239,3 +239,125 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d ); assert!(!chat.should_animate_terminal_title_action_required()); } + +#[tokio::test] +async fn thread_title_progress_renders_names_and_fallbacks_on_both_surfaces() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap()); + chat.local_settings.tui.animations = false; + let mut rendered = Vec::new(); + for (name, pending) in [ + (None, false), + (None, true), + (Some("Fix login timeout"), true), + (Some("Fix login timeout"), false), + ] { + chat.thread_name = name.map(str::to_owned); + chat.set_thread_title_generation_pending(pending); + chat.refresh_status_surfaces(); + let status = [ + StatusLineItem::ThreadName, + StatusLineItem::ThreadTitle, + StatusLineItem::SessionId, + ] + .into_iter() + .map(|item| chat.status_line_value_for_item(item).unwrap_or_default()) + .collect::>() + .join(" | "); + let title = [ + TerminalTitleItem::ThreadName, + TerminalTitleItem::Thread, + TerminalTitleItem::SessionId, + ] + .into_iter() + .map(|item| { + chat.terminal_title_value_for_item(item, Instant::now()) + .unwrap_or_default() + }) + .collect::>() + .join(" | "); + rendered.push(format!( + "name={name:?}, pending={pending}\nstatus: {status}\ntitle: {title}\ndefault title: {}", + chat.last_terminal_title.as_deref().unwrap() + )); + } + insta::assert_snapshot!( + "thread_title_generation_status_surfaces", + rendered.join("\n\n") + ); + chat.thread_name = Some(" ".to_string()); + assert_eq!( + chat.status_line_value_for_item(StatusLineItem::ThreadName), + None + ); + assert_eq!( + chat.terminal_title_value_for_item(TerminalTitleItem::ThreadName, Instant::now()), + None + ); +} + +#[tokio::test] +async fn thread_title_progress_animates_when_main_turn_is_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + chat.frame_requester = frame_requester; + chat.local_settings.tui.terminal_title = Some(Vec::new()); + chat.local_settings.tui.status_line = Some(vec!["thread-name".to_string()]); + chat.bottom_pane.set_task_running(/*running*/ false); + chat.set_thread_title_generation_pending(/*pending*/ true); + assert!(chat.terminal_title_next_refresh.is_some()); + while draw_rx.try_recv().is_ok() {} + + let now = Instant::now(); + chat.terminal_title_animation_origin = now; + assert_eq!( + chat.terminal_title_value_for_item(TerminalTitleItem::ThreadName, now), + Some("renaming... ⠋".to_string()) + ); + assert_eq!( + chat.terminal_title_value_for_item( + TerminalTitleItem::ThreadName, + now + Duration::from_millis(/*millis*/ 100) + ), + Some("renaming... ⠙".to_string()) + ); + chat.refresh_thread_title_progress_for_time_tick(); + assert!(draw_rx.try_recv().is_ok()); + + chat.local_settings.tui.status_line = Some(Vec::new()); + chat.local_settings.tui.terminal_title = Some(vec!["thread-name".to_string()]); + chat.refresh_status_surfaces(); + assert!(chat.terminal_title_next_refresh.is_some()); + while draw_rx.try_recv().is_ok() {} + chat.refresh_thread_title_progress_for_time_tick(); + assert!(draw_rx.try_recv().is_err()); + + chat.local_settings.tui.animations = false; + chat.refresh_status_surfaces(); + assert!(chat.terminal_title_next_refresh.is_none()); + assert_eq!(chat.last_terminal_title, Some("renaming... ⠋".to_string())); + chat.local_settings.tui.animations = true; + chat.set_thread_title_generation_pending(/*pending*/ false); + assert_eq!(chat.last_terminal_title, None); + assert!(chat.terminal_title_next_refresh.is_none()); +} + +#[tokio::test] +async fn thread_title_progress_preserves_suffix_after_truncation_and_in_default_footer() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.tui.animations = false; + chat.set_thread_title_generation_pending(/*pending*/ true); + chat.show_welcome_banner = false; + assert_chatwidget_snapshot!( + "default_footer_generating_thread_title", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 100)) + ); + chat.thread_name = Some("Long title ".repeat(/*n*/ 12)); + for item in [TerminalTitleItem::ThreadName, TerminalTitleItem::Thread] { + let title = chat + .terminal_title_value_for_item(item, Instant::now()) + .unwrap(); + assert!(title.ends_with("... ⠋"), "{title}"); + assert_eq!(title.chars().count(), 50); + } +} diff --git a/codex-rs/tui/src/chatwidget/thread_title_status.rs b/codex-rs/tui/src/chatwidget/thread_title_status.rs new file mode 100644 index 0000000000..e1c061c464 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/thread_title_status.rs @@ -0,0 +1,65 @@ +//! Title-generation progress on thread identity items in both status surfaces. +//! +//! The app owns requests across thread switches. Only the displayed thread's pending state +//! lives here; the existing terminal-title timer also redraws the footer while it is pending. + +use super::ChatWidget; +use super::status_surfaces::TERMINAL_TITLE_SPINNER_FRAMES; +use crate::bottom_pane::StatusLineItem; +use std::time::Instant; + +impl ChatWidget { + pub(crate) fn set_thread_title_generation_pending(&mut self, pending: bool) { + if self.status_state.thread_title_generation_pending != pending { + self.status_state.thread_title_generation_pending = pending; + self.refresh_status_surfaces(); + self.request_redraw(); + } + } + + pub(super) fn status_line_value_for_item(&mut self, item: StatusLineItem) -> Option { + let value = self.status_line_value(item); + if matches!( + item, + StatusLineItem::ThreadName | StatusLineItem::ThreadTitle | StatusLineItem::SessionId + ) { + self.with_thread_title_progress(value, Instant::now()) + } else { + value + } + } + + pub(super) fn with_thread_title_progress( + &self, + value: Option, + now: Instant, + ) -> Option { + if !self.status_state.thread_title_generation_pending { + return value; + } + let spinner = if self.local_settings.tui.animations { + self.terminal_title_spinner_frame_at(now) + } else { + TERMINAL_TITLE_SPINNER_FRAMES[0] + }; + let value = value.unwrap_or_else(|| "renaming...".to_string()); + Some(format!("{value} {spinner}")) + } + + pub(crate) fn refresh_thread_title_progress_for_time_tick(&mut self) { + if self.status_state.thread_title_generation_pending + && self.local_settings.tui.animations + && self.configured_status_line_items().iter().any(|item| { + matches!( + item.parse::(), + Ok(StatusLineItem::ThreadName + | StatusLineItem::ThreadTitle + | StatusLineItem::SessionId) + ) + }) + { + self.refresh_status_line(); + self.request_redraw(); + } + } +}