mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
-8
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
+7
@@ -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... ⠋
|
||||
+11
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-5
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user