diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b2df60de4c..cfe317bc8b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2472,6 +2472,13 @@ impl App { } } } + AppEvent::SuggestThreadName { + thread_id, + request_id, + } => { + self.suggest_thread_name(app_server, thread_id, request_id) + .await; + } AppEvent::ThreadTitleStarted { thread_id, destination, @@ -2513,6 +2520,17 @@ impl App { } } } + ThreadTitleDestination::RenameSuggestion { request_id } => { + let suggestion = result + .ok() + .and_then(|response| super::thread_title::parse_thread_title(&response)); + + self.chat_widget.apply_thread_name_suggestion( + thread_id, + request_id, + suggestion.as_deref(), + ); + } } } AppEvent::StopAgentsOverviewThread { thread_id } => { diff --git a/codex-rs/tui/src/app/thread_title.rs b/codex-rs/tui/src/app/thread_title.rs index 57da92d087..72abb5810b 100644 --- a/codex-rs/tui/src/app/thread_title.rs +++ b/codex-rs/tui/src/app/thread_title.rs @@ -1,13 +1,18 @@ //! Structured-output schema and normalization for generated TUI thread titles. use super::App; +use super::thread_events::ThreadBufferedEvent; use crate::app_event::AppEvent; use crate::app_event::ThreadTitleDestination; use crate::app_server_session::AppServerSession; use crate::temporary_structured_request::TemporaryStructuredThreadOptions; use crate::temporary_structured_request::run_temporary_structured_turn; use crate::temporary_structured_request::start_temporary_thread; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::UserInput; use codex_protocol::ThreadId; +use codex_protocol::models::MessagePhase; use codex_protocol::openai_models::ReasoningEffort; use serde::Deserialize; use serde_json::Value; @@ -17,6 +22,7 @@ use tokio::sync::mpsc; pub(super) const THREAD_TITLE_MAX_CHARS: usize = 36; const THREAD_TITLE_MODEL: &str = "gpt-5.6-luna"; pub(super) const THREAD_TITLE_PROMPT_MAX_BYTES: usize = 960; +const THREAD_TITLE_RECENT_MESSAGES: usize = 8; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -90,11 +96,20 @@ impl App { Ok(thread_id) => thread_id, Err(error) => { tracing::debug!(%error, "failed to start title-generation thread"); + if let ThreadTitleDestination::RenameSuggestion { request_id } = destination { + self.chat_widget.apply_thread_name_suggestion( + thread_id, request_id, /*suggestion*/ None, + ); + } return; } }; let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else { + if let ThreadTitleDestination::RenameSuggestion { request_id } = destination { + self.chat_widget + .apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None); + } return; }; @@ -124,6 +139,62 @@ impl App { }); }); } + + /// Suggest a title from stored and live conversation items without persisting it. + pub(super) async fn suggest_thread_name( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + request_id: uuid::Uuid, + ) { + if self.chat_widget.thread_id() != Some(thread_id) { + return; + } + + let Some(channel) = self.thread_event_channels.get(&thread_id) else { + self.chat_widget + .apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None); + return; + }; + + let conversation = { + let store = channel.store.lock().await; + let mut seen = std::collections::HashSet::new(); + + recent_conversation_messages( + store + .turns + .iter() + .flat_map(|turn| turn.items.iter()) + .chain(store.buffer.iter().filter_map(|event| { + let ThreadBufferedEvent::Notification(notification) = event else { + return None; + }; + + let ServerNotification::ItemCompleted(notification) = notification.as_ref() + else { + return None; + }; + + Some(¬ification.item) + })) + .filter(|item| seen.insert(item.id().to_string())), + ) + }; + + let Some(conversation) = conversation else { + self.chat_widget + .apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None); + return; + }; + + self.generate_thread_title( + app_server, + thread_id, + ThreadTitleDestination::RenameSuggestion { request_id }, + recent_conversation_thread_title_prompt(&conversation), + ); + } } /// Constrain generated metadata to one nonempty title within the display limit. @@ -169,6 +240,112 @@ pub(super) fn thread_title_prompt(user_message: &str) -> String { format!("{prefix}{user_message}") } +/// Format recent substantive messages chronologically without trusting their markup. +pub(super) fn recent_conversation_messages<'a, I>(items: I) -> Option +where + I: IntoIterator, + I::IntoIter: DoubleEndedIterator, +{ + let mut messages = items + .into_iter() + .rev() + .filter_map(|item| match item { + ThreadItem::UserMessage { content, .. } => { + let text = content + .iter() + .filter_map(|input| match input { + UserInput::Text { text, .. } => { + Some(crate::ide_context::extract_prompt_request_with_offset(text).0) + } + _ => None, + }) + .collect::>() + .join("\n"); + + (!text.trim().is_empty()).then_some(("user", text)) + } + ThreadItem::AgentMessage { text, phase, .. } + if !matches!(phase, Some(MessagePhase::Commentary)) && !text.trim().is_empty() => + { + Some(("assistant", text.clone())) + } + _ => None, + }) + .take(THREAD_TITLE_RECENT_MESSAGES) + .map(|(role, text)| { + let escaped = text + .trim() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + + (role, escaped) + }) + .collect::>(); + + if messages.is_empty() { + return None; + } + + messages.reverse(); + + let conversation_bytes = THREAD_TITLE_PROMPT_MAX_BYTES + .saturating_sub(recent_conversation_thread_title_prompt("").len()); + let markup_bytes = "\n".len() + + "\n".len() + + messages.len().saturating_sub(/*rhs*/ 1) + + messages + .iter() + .map(|(role, _)| "".len() + role.len()) + .sum::(); + let message_bytes = conversation_bytes.saturating_sub(markup_bytes) / messages.len(); + let content_bytes = messages.iter().map(|(_, text)| text.len()).sum::(); + let should_truncate = content_bytes > conversation_bytes.saturating_sub(markup_bytes); + let messages = messages + .into_iter() + .map(|(role, mut text)| { + if should_truncate && text.len() > message_bytes { + let mut end = message_bytes; + while !text.is_char_boundary(end) { + end -= 1; + } + if let Some(entity_start) = text[..end].rfind('&') + && !text[entity_start..end].contains(';') + { + end = entity_start; + } + text.truncate(end); + } + + format!("{text}") + }) + .collect::>(); + + Some(format!( + "\n{}\n", + messages.join("\n") + )) +} + +/// Bound the entire suggestion prompt while preserving complete Unicode characters. +pub(super) fn recent_conversation_thread_title_prompt(conversation: &str) -> String { + let instructions = thread_title_instructions(); + let prefix = format!( + "{instructions}\n\ +Prioritize the current task and latest substantive user request.\n\n\ +Recent conversation messages:\n" + ); + let conversation = conversation.trim(); + let remaining_bytes = THREAD_TITLE_PROMPT_MAX_BYTES.saturating_sub(prefix.len()); + let mut start = conversation.len().saturating_sub(remaining_bytes); + while !conversation.is_char_boundary(start) { + start += 1; + } + let conversation = &conversation[start..]; + + format!("{prefix}{conversation}") +} + /// Normalize a generated title and truncate it without splitting Unicode characters. pub(super) fn parse_thread_title(response: &str) -> Option { if !response.trim_start().starts_with('{') { diff --git a/codex-rs/tui/src/app/thread_title_tests.rs b/codex-rs/tui/src/app/thread_title_tests.rs index f914e6a29e..7d0e36c0d7 100644 --- a/codex-rs/tui/src/app/thread_title_tests.rs +++ b/codex-rs/tui/src/app/thread_title_tests.rs @@ -1,10 +1,31 @@ use super::THREAD_TITLE_MAX_CHARS; use super::THREAD_TITLE_PROMPT_MAX_BYTES; +use super::THREAD_TITLE_RECENT_MESSAGES; use super::parse_thread_title; +use super::recent_conversation_messages; +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_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::test_support::PathBufExt; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::models::MessagePhase; +use core_test_support::responses; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; +use tempfile::tempdir; +use tokio::sync::mpsc::unbounded_channel; const EXPECTED_THREAD_TITLE_INSTRUCTIONS: &str = concat!( "Generate a concise, single-line task title of at most 36 characters ", @@ -91,6 +112,284 @@ async fn manual_rename_invalidates_pending_automatic_title_before_notification() Ok(()) } +#[tokio::test] +async fn slash_rename_generates_editable_title_through_embedded_app_server() +-> color_eyre::Result<()> { + let server = wiremock::MockServer::start().await; + let response = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("title-response"), + responses::ev_assistant_message("title-message", r#"{"title":"Fix login timeout"}"#), + responses::ev_completed("title-response"), + ]), + ) + .await; + let codex_home = tempdir()?; + let provider_id = "thread-title-test"; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "model = \"gpt-5.2\"\n\ + model_provider = \"{provider_id}\"\n\n\ + [model_providers.{provider_id}]\n\ + name = \"Thread title test\"\n\ + base_url = \"{}/v1\"\n\ + wire_api = \"responses\"\n\ + request_max_retries = 0\n\ + stream_max_retries = 0\n", + server.uri() + ), + )?; + + let mut app = make_test_app().await; + let (event_tx, mut event_rx) = unbounded_channel(); + app.app_event_tx = AppEventSender::new(event_tx); + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = + codex_state::SqliteConfig::new_for_testing(codex_home.path().to_path_buf().abs()); + app.config.model = Some("gpt-5.2".to_string()); + app.config.model_provider_id = provider_id.to_string(); + app.config.model_provider = ModelProviderInfo { + name: "Thread title test".to_string(), + base_url: Some(format!("{}/v1", server.uri())), + request_max_retries: Some(0), + stream_max_retries: Some(0), + ..ModelProviderInfo::default() + }; + + let mut tui = crate::tui::test_support::make_test_tui()?; + 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.replace_chat_widget_with_app_server_thread( + &mut tui, + started, + ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + app.ensure_thread_channel(thread_id) + .store + .lock() + .await + .turns + .push(Turn { + id: "existing-turn".to_string(), + items: vec![title_user_message("user-message", "Fix the login timeout")], + items_view: Default::default(), + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }); + 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")); + + enum TitleDriveEvent { + Ui(Box), + Server(AppServerEvent), + } + + loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async { + tokio::select! { + event = event_rx.recv() => event.map(|event| TitleDriveEvent::Ui(Box::new(event))), + event = app_server.next_event() => event.map(TitleDriveEvent::Server), + } + }) + .await? + .ok_or_else(|| color_eyre::eyre::eyre!("title event stream ended"))?; + + match event { + TitleDriveEvent::Ui(event) => { + let generated = matches!(event.as_ref(), AppEvent::GeneratedThreadTitle { .. }); + app.handle_event(&mut tui, &mut app_server, *event).await?; + if generated { + break; + } + } + TitleDriveEvent::Server(event) => { + app.handle_app_server_event(&app_server, event).await; + } + } + } + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert!(popup.contains("Fix login timeout")); + assert!(!popup.contains("Generating a title suggestion")); + assert!(app.temporary_structured_requests.is_empty()); + + let request = response.single_request(); + assert!( + request + .body_json() + .to_string() + .contains("Fix the login timeout") + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[test] +fn recent_conversation_messages_escape_markup_and_ignore_commentary() { + let items = vec![ + title_user_message("user-1", " Investigate & slow tests "), + title_agent_message( + "commentary", + "Checking files", + Some(MessagePhase::Commentary), + ), + title_user_message("user-2", "Fix > flaky tests"), + title_agent_message( + "answer", + " Tests now pass ", + Some(MessagePhase::FinalAnswer), + ), + ]; + + assert_eq!( + recent_conversation_messages(&items), + Some( + "\nInvestigate <flaky> & slow tests\nFix > flaky tests\nTests now pass\n" + .to_string() + ) + ); +} + +#[test] +fn recent_conversation_messages_strip_ide_context_before_escaping() { + let ide_context = "x".repeat(THREAD_TITLE_PROMPT_MAX_BYTES + 1); + let user_message = format!( + "# Context from my IDE setup:\n{ide_context}\n## My request for Codex:\n\nEarlier request\n## My request for Codex:\n Fix & retries " + ); + let items = vec![title_user_message("user-1", &user_message)]; + + assert_eq!( + recent_conversation_messages(&items), + Some( + "\nFix <login> & retries\n" + .to_string() + ) + ); +} + +#[test] +fn recent_conversation_messages_keep_only_the_latest_substantive_items() { + let items = (0..THREAD_TITLE_RECENT_MESSAGES + 2) + .map(|index| title_user_message(&index.to_string(), &format!("message-{index}"))) + .collect::>(); + + let expected_messages = (2..THREAD_TITLE_RECENT_MESSAGES + 2) + .map(|index| format!("message-{index}")) + .collect::>() + .join("\n"); + + assert_eq!( + recent_conversation_messages(&items), + Some(format!( + "\n{expected_messages}\n" + )) + ); +} + +#[test] +fn recent_conversation_messages_require_substantive_content() { + assert_eq!(recent_conversation_messages(&[]), None); + + let items = vec![ + title_user_message("blank", " \n\t "), + title_agent_message("commentary", "Thinking", Some(MessagePhase::Commentary)), + ]; + + assert_eq!(recent_conversation_messages(&items), None); +} + +#[test] +fn recent_conversation_prompt_trims_messages_and_prioritizes_latest_request() { + let prompt = recent_conversation_thread_title_prompt( + " User: Fix login\nAssistant: Investigating the auth flow \n", + ); + + assert_eq!( + prompt, + format!( + "{EXPECTED_THREAD_TITLE_INSTRUCTIONS}\nPrioritize the current task and latest substantive user request.\n\nRecent conversation messages:\nUser: Fix login\nAssistant: Investigating the auth flow" + ) + ); +} + +#[test] +fn recent_conversation_prompt_keeps_latest_unicode_characters() { + let conversation = format!("discarded-{}x", "🚀".repeat(THREAD_TITLE_PROMPT_MAX_BYTES)); + let prompt = recent_conversation_thread_title_prompt(&conversation); + let expected_instructions = format!( + "{EXPECTED_THREAD_TITLE_INSTRUCTIONS}\nPrioritize the current task and latest substantive user request.\n\n" + ); + let prefix = format!("{expected_instructions}Recent conversation messages:\n"); + let remaining_bytes = THREAD_TITLE_PROMPT_MAX_BYTES - prefix.len(); + let expected = format!("{}x", "🚀".repeat((remaining_bytes - 1) / '🚀'.len_utf8())); + + assert_eq!( + prompt.rsplit_once("Recent conversation messages:\n"), + Some((expected_instructions.as_str(), expected.as_str())) + ); + assert!(prompt.len() <= THREAD_TITLE_PROMPT_MAX_BYTES); +} + +#[test] +fn recent_conversation_prompt_preserves_latest_user_request_and_complete_markup() { + let items = vec![ + title_user_message("user", "Fix & login regressions"), + title_agent_message( + "assistant", + &format!("{}&<>", "🚀".repeat(THREAD_TITLE_PROMPT_MAX_BYTES)), + Some(MessagePhase::FinalAnswer), + ), + ]; + let conversation = recent_conversation_messages(&items).expect("substantive conversation"); + let prompt = recent_conversation_thread_title_prompt(&conversation); + + assert!(prompt.len() <= THREAD_TITLE_PROMPT_MAX_BYTES); + assert!(prompt.contains("\n")); + assert!(prompt.contains("")); + assert!(prompt.contains( + "Fix <authentication> & login regressions" + )); + assert!(prompt.contains("")); + assert_eq!(prompt.matches("").count(), 2); +} + +#[test] +fn recent_conversation_prompt_never_splits_an_escaped_markup_entity() { + let items = vec![title_user_message( + "user", + &"<&>".repeat(THREAD_TITLE_PROMPT_MAX_BYTES), + )]; + let conversation = recent_conversation_messages(&items).expect("substantive conversation"); + let prompt = recent_conversation_thread_title_prompt(&conversation); + + assert!(prompt.len() <= THREAD_TITLE_PROMPT_MAX_BYTES); + let text = conversation + .strip_prefix("\n") + .and_then(|text| text.strip_suffix("\n")) + .expect("complete message markup"); + for fragment in text.split('&').skip(/*n*/ 1) { + assert!( + fragment.starts_with("lt;") + || fragment.starts_with("gt;") + || fragment.starts_with("amp;") + ); + } +} + #[test] fn normalizes_generated_title_whitespace() { assert_eq!( @@ -161,3 +460,24 @@ fn truncates_generated_titles_without_splitting_unicode() { assert_eq!(parse_thread_title(&response), Some(expected)); } + +fn title_user_message(id: &str, text: &str) -> ThreadItem { + ThreadItem::UserMessage { + id: id.to_string(), + client_id: None, + content: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + } +} + +fn title_agent_message(id: &str, text: &str, phase: Option) -> ThreadItem { + ThreadItem::AgentMessage { + id: id.to_string(), + text: text.to_string(), + phase, + memory_citation: None, + delivery: None, + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a01a011918..b5e00f55cc 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -195,11 +195,13 @@ pub(crate) enum TranscriptExportDestination { File(PathBuf), } -/// Deliver a generated title to its originating automatic rename. +/// Deliver a generated title to its originating automatic rename or editable prompt. #[derive(Debug)] pub(crate) enum ThreadTitleDestination { /// Replace the provisional name only if the user has not renamed the thread. Automatic { expected_title: String }, + /// Prefill only the still-active rename prompt with the matching request ID. + RenameSuggestion { request_id: Uuid }, } #[allow(clippy::large_enum_variant)] @@ -226,6 +228,11 @@ pub(crate) enum AppEvent { thread_id: ThreadId, name: String, }, + /// Generate an editable title suggestion for the active rename prompt. + SuggestThreadName { + thread_id: ThreadId, + request_id: Uuid, + }, /// Register a hidden title-generation thread started in the background. ThreadTitleStarted { thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index e4d1b5f0a5..6d33284eb6 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -56,6 +56,15 @@ pub(crate) trait BottomPaneView: Renderable { None } + /// Apply a matching background suggestion when this view supports text prefills. + fn apply_text_suggestion( + &mut self, + _request_id: uuid::Uuid, + _suggestion: Option<&str>, + ) -> bool { + false + } + /// Active tab id for tabbed list-based views. #[allow(dead_code)] fn active_tab_id(&self) -> Option<&str> { diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 170cd4d9bb..4a281e3de5 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -12,6 +12,7 @@ use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::Widget; use std::cell::RefCell; use std::time::Instant; +use uuid::Uuid; use crate::key_hint; use crate::key_hint::has_ctrl_or_alt; @@ -35,6 +36,12 @@ use super::textarea::TextAreaState; /// Callback invoked when the user submits a custom prompt. pub(crate) type PromptSubmitted = Box; +/// Correlates a pending generated prefill with its eventual display label. +struct PendingTextSuggestion { + request_id: Uuid, + applied_label: String, +} + /// Minimal multi-line text input view to collect custom review instructions. pub(crate) struct CustomPromptView { title: String, @@ -47,6 +54,8 @@ pub(crate) struct CustomPromptView { textarea_state: RefCell, paste_burst: PasteBurst, completion: Option, + pending_suggestion: Option, + user_edited: bool, } impl CustomPromptView { @@ -72,6 +81,8 @@ impl CustomPromptView { textarea_state: RefCell::new(TextAreaState::default()), paste_burst: PasteBurst::default(), completion: None, + pending_suggestion: None, + user_edited: false, } } @@ -86,9 +97,31 @@ impl CustomPromptView { self.textarea.enter_vim_insert_mode(); } + /// Opt this prompt into one correlated background text suggestion. + pub(crate) fn with_text_suggestion( + mut self, + request_id: Uuid, + loading_label: String, + applied_label: String, + ) -> Self { + self.context_label = Some(loading_label); + self.pending_suggestion = Some(PendingTextSuggestion { + request_id, + applied_label, + }); + self + } + fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { + let previous_text = self + .pending_suggestion + .as_ref() + .filter(|_| !self.user_edited) + .map(|_| self.textarea.text().to_string()); + if self.textarea.is_vim_operator_pending() { self.textarea.input(key_event); + self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text); return; } @@ -113,6 +146,7 @@ impl CustomPromptView { { self.paste_burst.extend_window(now); self.textarea.insert_str("\n"); + self.user_edited = true; return; } if modifiers == KeyModifiers::NONE { @@ -152,6 +186,8 @@ impl CustomPromptView { self.paste_burst.clear_after_explicit_paste(); } } + + self.user_edited |= previous_text.is_some_and(|text| self.textarea.text() != text); } } @@ -186,9 +222,35 @@ impl BottomPaneView for CustomPromptView { return false; } self.textarea.insert_str(&pasted); + self.user_edited = true; self.paste_burst.clear_after_explicit_paste(); true } + + fn apply_text_suggestion(&mut self, request_id: Uuid, suggestion: Option<&str>) -> bool { + if self.completion.is_some() { + return false; + } + + let Some(pending) = self + .pending_suggestion + .take_if(|pending| pending.request_id == request_id) + else { + return false; + }; + + self.context_label = None; + + if !self.user_edited + && let Some(suggestion) = suggestion + { + self.textarea.set_text_clearing_elements(suggestion); + self.textarea.set_cursor(suggestion.len()); + self.context_label = Some(pending.applied_label); + } + + true + } } #[cfg(test)] diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs index 4160acfa3c..ae483385c5 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs @@ -210,6 +210,83 @@ fn escape_without_vim_cancels_prompt() { assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); } +#[test] +fn background_prefill_requires_a_matching_unedited_prompt() { + let request_id = Uuid::new_v4(); + let (view, submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + + assert!(!view.apply_text_suggestion(Uuid::new_v4(), Some("Stale"))); + assert!(view.apply_text_suggestion(request_id, Some("Suggested text"))); + assert_eq!(view.context_label.as_deref(), Some("Ready")); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), Instant::now()); + + assert_eq!(submitted_rx.try_recv(), Ok("Suggested text".to_string())); + assert!(!view.apply_text_suggestion(request_id, Some("Late"))); +} + +#[test] +fn background_prefill_failure_clears_loading_without_changing_text() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + view.textarea.set_text_clearing_elements("Existing title"); + + assert!(view.apply_text_suggestion(request_id, /*suggestion*/ None)); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("Existing title", None) + ); +} + +#[test] +fn background_prefill_preserves_typing_even_after_the_text_is_deleted() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Char('x')), Instant::now()); + view.handle_key_event_at(KeyEvent::from(KeyCode::Backspace), Instant::now()); + + assert!(view.apply_text_suggestion(request_id, Some("Generated"))); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("", None) + ); +} + +#[test] +fn background_prefill_preserves_pasted_text() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + + assert!(view.handle_paste("Manual title".to_string())); + assert!(view.apply_text_suggestion(request_id, Some("Generated"))); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("Manual title", None) + ); +} + +#[test] +fn background_prefill_survives_cursor_movement() { + let request_id = Uuid::new_v4(); + let (view, _submitted_rx) = custom_prompt_view(); + let mut view = view.with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + view.textarea.set_text_clearing_elements("Existing"); + view.textarea.set_cursor("Existing".len()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Left), Instant::now()); + + assert!(view.apply_text_suggestion(request_id, Some("Generated"))); + assert_eq!( + (view.textarea.text(), view.context_label.as_deref()), + ("Generated", Some("Ready")) + ); +} + fn custom_prompt_view() -> (CustomPromptView, Receiver) { let (submitted, submitted_rx) = std::sync::mpsc::channel(); let view = CustomPromptView::new( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8eed9f9e07..5e62eca887 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1303,6 +1303,25 @@ impl BottomPane { .and_then(|view| view.active_tab_id()) } + /// Forward a suggestion to its matching prompt, even beneath another view. + pub(crate) fn apply_text_suggestion( + &mut self, + request_id: uuid::Uuid, + suggestion: Option<&str>, + ) -> bool { + let changed = self + .view_stack + .iter_mut() + .rev() + .any(|view| view.apply_text_suggestion(request_id, suggestion)); + + if changed { + self.request_redraw(); + } + + changed + } + pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool { let is_match = self .view_stack @@ -2322,6 +2341,41 @@ mod tests { ); } + #[test] + fn apply_text_suggestion_updates_matching_prompt_beneath_an_overlay() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let request_id = uuid::Uuid::new_v4(); + let prompt = custom_prompt_view::CustomPromptView::new( + "Rename thread".to_string(), + "Type a name".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ None, + Box::new(|_| {}), + ) + .with_text_suggestion(request_id, "Loading".to_string(), "Ready".to_string()); + pane.show_text_prompt(prompt); + pane.push_view(Box::new(CompletingView { + id: Some("overlay"), + complete: false, + })); + + assert!(pane.apply_text_suggestion(request_id, Some("Fix login timeout"))); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let rendered = render_snapshot( + &pane, + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8, + ), + ); + + assert!(rendered.contains("Fix login timeout")); + assert!(rendered.contains("Ready")); + assert!(!rendered.contains("Loading")); + } + #[test] fn dismiss_app_server_request_returns_false_when_no_view_matches() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 9206ccf693..b38e33b56f 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -417,7 +417,10 @@ impl ChatWidget { } else { "Name thread" }; - let view = CustomPromptView::new( + let suggestion_request = self + .thread_id + .map(|thread_id| (thread_id, uuid::Uuid::new_v4())); + let mut view = CustomPromptView::new( title.to_string(), "Type a name and press Enter".to_string(), /*initial_text*/ existing_name.unwrap_or_default().to_string(), @@ -432,7 +435,32 @@ impl ChatWidget { tx.set_thread_name(name); }), ); + if let Some((_, request_id)) = suggestion_request { + view = view.with_text_suggestion( + request_id, + "Generating a title suggestion…".to_string(), + "Suggested from this conversation".to_string(), + ); + } self.bottom_pane.show_text_prompt(view); + if let Some((thread_id, request_id)) = suggestion_request { + self.app_event_tx.send(AppEvent::SuggestThreadName { + thread_id, + request_id, + }); + } + } + + pub(crate) fn apply_thread_name_suggestion( + &mut self, + thread_id: ThreadId, + request_id: uuid::Uuid, + suggestion: Option<&str>, + ) { + if self.thread_id == Some(thread_id) { + self.bottom_pane + .apply_text_suggestion(request_id, suggestion); + } } pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_rename_suggestion_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_rename_suggestion_loading.snap new file mode 100644 index 0000000000..e361490eef --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_rename_suggestion_loading.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: popup +--- +â–Œ Rename thread +â–Œ Generating a title suggestion… +â–Œ +â–Œ Current project title + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_rename_suggestion_prefilled.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_rename_suggestion_prefilled.snap new file mode 100644 index 0000000000..55209a565c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_rename_suggestion_prefilled.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: popup +--- +â–Œ Rename thread +â–Œ Suggested from this conversation +â–Œ +â–Œ Fix login timeout + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index b749244d02..12d6efcc2b 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1621,6 +1621,37 @@ async fn live_app_server_manual_thread_name_is_visible_before_notification() { assert_eq!(drain_insert_history(&mut rx).len(), 1); } +#[tokio::test] +async fn thread_name_suggestion_requires_matching_thread_and_request() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + let request_id = uuid::Uuid::new_v4(); + chat.thread_id = Some(thread_id); + + let view = CustomPromptView::new( + "Rename thread".to_string(), + "Type a name and press Enter".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ None, + Box::new(|_| {}), + ) + .with_text_suggestion(request_id, "Loading".into(), "Ready".into()); + chat.bottom_pane.show_text_prompt(view); + + chat.apply_thread_name_suggestion(ThreadId::new(), request_id, Some("Wrong thread")); + chat.apply_thread_name_suggestion(thread_id, uuid::Uuid::new_v4(), Some("Wrong request")); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("Loading")); + assert!(!popup.contains("Wrong")); + + chat.apply_thread_name_suggestion(thread_id, request_id, Some("Suggested title")); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("Ready")); + assert!(popup.contains("Suggested title")); +} + #[tokio::test] async fn live_app_server_thread_closed_requests_immediate_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index d2dc6e8b75..2433ee6e05 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1214,6 +1214,78 @@ async fn slash_rename_prefills_existing_thread_name() { ); } +#[tokio::test] +async fn slash_rename_requests_and_prefills_an_editable_title_suggestion() { + 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.thread_name = Some("Current project title".to_string()); + + chat.dispatch_command(SlashCommand::Rename); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_rename_suggestion_loading", popup); + + let request_id = match rx.try_recv() { + Ok(AppEvent::SuggestThreadName { + thread_id: requested_thread_id, + request_id, + }) => { + assert_eq!(requested_thread_id, thread_id); + request_id + } + other => panic!("expected title suggestion request, got {other:?}"), + }; + + chat.apply_thread_name_suggestion(thread_id, request_id, Some("Fix login timeout")); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_rename_suggestion_prefilled", popup); + assert_eq!(chat.thread_name.as_deref(), Some("Current project title")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Fix login timeout" + ); +} + +#[tokio::test] +async fn slash_rename_preserves_manual_edits_when_suggestion_arrives() { + 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.dispatch_command(SlashCommand::Rename); + + let request_id = match rx.try_recv() { + Ok(AppEvent::SuggestThreadName { + thread_id: requested_thread_id, + request_id, + }) => { + assert_eq!(requested_thread_id, thread_id); + request_id + } + other => panic!("expected title suggestion request, got {other:?}"), + }; + + chat.handle_paste("My own title".to_string()); + chat.apply_thread_name_suggestion(thread_id, request_id, Some("Generated title")); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("My own title")); + assert!(!popup.contains("Generated title")); + assert!(!popup.contains("Generating a title suggestion")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "My own title" + ); +} + #[tokio::test] async fn slash_rename_without_existing_thread_name_starts_empty() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;