Improve automatic thread naming in the TUI (#42749)

## Why

Automatic title generation used a provisional thread name and only applied the
generated result while its thread remained active. Switching threads could
therefore leave the originating thread without its generated name.

## What changed

- Track title-generation requests by thread so results are persisted for the
  originating thread, while preserving any name set before generation finishes.
- Show a `renaming...` spinner instead of a provisional name, including across
  thread switches and failure paths.
- Add `thread-name` items to the status line and terminal title, and include the
  generated thread name in both default layouts.

## Testing

- Cover automatic naming, manual-name precedence, thread switching, failed
  requests, deduplication, and status-surface rendering and animation.

GitOrigin-RevId: a03ae4849bedec9f62f256fadad72c1685ce46e0
This commit is contained in:
Felipe Coury
2026-09-04 12:20:57 +00:00
committed by copyberry
parent 8e85265c39
commit a1294e57f1
32 changed files with 642 additions and 181 deletions
+2 -2
View File
@@ -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<Vec<String>>,
@@ -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)]
+2 -2
View File
@@ -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"
},
+4
View File
@@ -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<ThreadId, ThreadEventChannel>,
temporary_structured_requests: HashMap<ThreadId, mpsc::UnboundedSender<ServerNotification>>,
/// Track title generation across thread switches and deduplicate automatic requests.
pending_thread_titles: HashSet<(ThreadId, ThreadTitleDestination)>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
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<Rect> {
self.sync_thread_title_progress();
let dashboard_visible = self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
+9 -5
View File
@@ -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");
}
+2
View File
@@ -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;
+2
View File
@@ -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
}
+1
View File
@@ -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(),
+2
View File
@@ -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(),
+10 -31
View File
@@ -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::<Vec<_>>()
.join(" ")
.chars()
.take(super::thread_title::THREAD_TITLE_MAX_CHARS)
.collect::<String>();
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()
+28
View File
@@ -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);
+227 -34
View File
@@ -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<AppEvent>),
@@ -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<MessagePhase>) -> 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(())
}
+3 -3
View File
@@ -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 },
}
@@ -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
@@ -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,
@@ -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,
}
}
@@ -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,
@@ -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),
+2 -15
View File
@@ -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<crate::pets::PetImageSupport>,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
pending_automatic_thread_names: HashSet<String>,
thread_rename_block_message: Option<String>,
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<ThreadId>) -> 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`.
///
@@ -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,
+3 -20
View File
@@ -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<String>,
) {
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();
@@ -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... ⠋
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -116,6 +116,7 @@ pub(super) struct StatusState {
pub(super) terminal_title_status_kind: TerminalTitleStatusKind,
pub(super) retry_status_header: Option<String>,
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,
}
}
}
+51 -21
View File
@@ -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<Duration> {
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<String> {
pub(super) fn status_line_value(&mut self, item: StatusLineItem) -> Option<String> {
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;
+37 -29
View File
@@ -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::<String>();
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::<String>();
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]
@@ -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
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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);
}
}
@@ -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<String> {
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<String>,
now: Instant,
) -> Option<String> {
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::<StatusLineItem>(),
Ok(StatusLineItem::ThreadName
| StatusLineItem::ThreadTitle
| StatusLineItem::SessionId)
)
})
{
self.refresh_status_line();
self.request_redraw();
}
}
}