From f00ebd615c303ae94162f9662efc818ff7aaa0b6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 22 May 2026 15:21:55 -0300 Subject: [PATCH] feat(tui): render next prompt suggestions --- codex-rs/tui/src/app.rs | 21 ++ codex-rs/tui/src/app/background_requests.rs | 47 +++++ codex-rs/tui/src/app/event_dispatch.rs | 9 + codex-rs/tui/src/app/input.rs | 11 ++ .../tui/src/app/next_prompt_suggestion.rs | 185 ++++++++++++++++++ codex-rs/tui/src/app/session_lifecycle.rs | 3 + codex-rs/tui/src/app/test_support.rs | 2 + codex-rs/tui/src/app/tests.rs | 134 +++++++++++++ codex-rs/tui/src/app/thread_routing.rs | 16 ++ codex-rs/tui/src/app_backtrack.rs | 2 + codex-rs/tui/src/app_event.rs | 7 + codex-rs/tui/src/chatwidget.rs | 4 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + .../src/chatwidget/next_prompt_suggestion.rs | 72 +++++++ codex-rs/tui/src/chatwidget/settings.rs | 1 + codex-rs/tui/src/chatwidget/side.rs | 7 +- ...renders_as_empty_composer_placeholder.snap | 9 + codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../tests/next_prompt_suggestion.rs | 23 +++ 20 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/app/next_prompt_suggestion.rs create mode 100644 codex-rs/tui/src/chatwidget/next_prompt_suggestion.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__next_prompt_suggestion_renders_as_empty_composer_placeholder.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/next_prompt_suggestion.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3c3d15086f85..3e4f0b1f78db 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -205,6 +205,7 @@ mod event_dispatch; mod history_ui; mod input; mod loaded_threads; +mod next_prompt_suggestion; mod pending_interactive_replay; mod pets; mod platform_actions; @@ -551,6 +552,10 @@ pub(crate) struct App { pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, pending_startup_thread_start: bool, + /// Monotonic token used to ignore async suggestion results from older UI state. + next_prompt_suggestion_generation: u64, + /// Current fire-and-forget suggestion request, if one is still in flight. + pending_next_prompt_suggestion: Option, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. @@ -577,6 +582,17 @@ impl RuntimePermissionProfileOverride { } } +struct PendingNextPromptSuggestion { + task: JoinHandle<()>, + cancel_request: Option, +} + +struct NextPromptSuggestionCancelRequest { + request_handle: AppServerRequestHandle, + thread_id: ThreadId, + cancellation_token: String, +} + fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { let TypedRequestError::Server { source, .. } = error else { return None; @@ -1007,6 +1023,8 @@ See the Codex keymap documentation for supported actions and examples." pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start, + next_prompt_suggestion_generation: 0, + pending_next_prompt_suggestion: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; @@ -1018,6 +1036,7 @@ See the Codex keymap documentation for supported actions and examples." let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; + app.request_next_prompt_suggestion_for_thread(&app_server, thread_id); if should_prompt_for_paused_goal_after_startup_resume { app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id) .await; @@ -1222,6 +1241,7 @@ See the Codex keymap documentation for supported actions and examples." self.handle_key_event(tui, app_server, key_event).await; } TuiEvent::Paste(pasted) => { + self.cancel_pending_next_prompt_suggestion(); // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 @@ -1326,6 +1346,7 @@ See the Codex keymap documentation for supported actions and examples." impl Drop for App { fn drop(&mut self) { + self.cancel_pending_next_prompt_suggestion(); if let Err(err) = self.chat_widget.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title on app drop"); } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index dcf1f672bee5..1a3465b3dbe4 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -15,6 +15,8 @@ use codex_app_server_protocol::MarketplaceRemoveParams; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::ThreadSuggestNextPromptParams; +use codex_app_server_protocol::ThreadSuggestNextPromptResponse; use codex_app_server_protocol::RequestId; @@ -23,6 +25,51 @@ use crate::hooks_rpc::write_hook_trust; use crate::hooks_rpc::write_hook_trusts; use codex_utils_absolute_path::AbsolutePathBuf; +/// Requests one best-effort next-prompt suggestion from app-server. +/// +/// The caller owns cancellation and stale-result suppression. This helper only +/// performs the typed RPC and returns the already-filtered optional text. +pub(super) async fn fetch_next_prompt_suggestion( + request_handle: AppServerRequestHandle, + thread_id: ThreadId, + cancellation_token: String, +) -> Result> { + let request_id = RequestId::String(format!("next-prompt-suggestion-{}", Uuid::new_v4())); + let response: ThreadSuggestNextPromptResponse = request_handle + .request_typed(ClientRequest::ThreadSuggestNextPrompt { + request_id, + params: ThreadSuggestNextPromptParams { + thread_id: thread_id.to_string(), + cancellation_token: Some(cancellation_token), + cancel: None, + }, + }) + .await + .wrap_err("thread/suggestNextPrompt failed in TUI")?; + Ok(response.suggestion) +} + +/// Cancels one in-flight best-effort next-prompt suggestion request. +pub(super) async fn cancel_next_prompt_suggestion( + request_handle: AppServerRequestHandle, + thread_id: ThreadId, + cancellation_token: String, +) -> Result<()> { + let request_id = RequestId::String(format!("next-prompt-suggestion-cancel-{}", Uuid::new_v4())); + let _response: ThreadSuggestNextPromptResponse = request_handle + .request_typed(ClientRequest::ThreadSuggestNextPrompt { + request_id, + params: ThreadSuggestNextPromptParams { + thread_id: thread_id.to_string(), + cancellation_token: Some(cancellation_token), + cancel: Some(/*cancel*/ true), + }, + }) + .await + .wrap_err("thread/suggestNextPrompt cancellation failed in TUI")?; + Ok(()) +} + impl App { pub(super) fn fetch_mcp_inventory( &mut self, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index e150dbccd21b..94440adf129d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -27,6 +27,15 @@ impl App { self.handle_startup_thread_started(app_server, result) .await?; } + AppEvent::NextPromptSuggestionReady { + generation, + thread_id, + latency_ms, + result, + } => { + self.handle_next_prompt_suggestion_ready(generation, thread_id, latency_ms, result); + tui.frame_requester().schedule_frame(); + } AppEvent::ClearUi => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index f6530cad26a2..8e6bfd8ffc25 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -97,6 +97,17 @@ impl App { app_server: &mut AppServerSession, key_event: KeyEvent, ) { + if self.next_prompt_suggestion_key_should_accept(key_event) { + self.accept_next_prompt_suggestion(); + return; + } + if matches!( + key_event.code, + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete + ) { + self.cancel_pending_next_prompt_suggestion(); + } + // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as // agent-switch shortcuts when the composer is empty so we never steal the expected diff --git a/codex-rs/tui/src/app/next_prompt_suggestion.rs b/codex-rs/tui/src/app/next_prompt_suggestion.rs new file mode 100644 index 000000000000..977a5c08e659 --- /dev/null +++ b/codex-rs/tui/src/app/next_prompt_suggestion.rs @@ -0,0 +1,185 @@ +//! Owns asynchronous next-prompt suggestion state for the active TUI thread. +//! +//! `App` starts one fire-and-forget RPC after stable conversation boundaries, +//! tags it with a generation number, and forwards only the newest result for the +//! still-visible thread into `ChatWidget`. Typing, paste, turn transitions, +//! backtrack, and thread replacement invalidate pending work so a late response +//! cannot overwrite newer composer state. + +use super::*; + +impl App { + /// Clears both pending generation work and the visible ghost-text candidate. + pub(crate) fn clear_next_prompt_suggestion(&mut self) { + self.cancel_pending_next_prompt_suggestion(); + self.chat_widget.clear_next_prompt_suggestion(); + } + + /// Starts a suggestion request for the currently displayed primary thread. + /// + /// This is the normal post-turn entry point. Callers that already know the + /// attached thread id, such as resume/fork lifecycle code, should use + /// `request_next_prompt_suggestion_for_thread` so transient display state does + /// not suppress the initial request. + pub(super) fn request_next_prompt_suggestion(&mut self, app_server: &AppServerSession) { + let Some(thread_id) = self.current_displayed_thread_id() else { + tracing::debug!("skipping next prompt suggestion without displayed thread"); + return; + }; + self.request_next_prompt_suggestion_for_thread(app_server, thread_id); + } + + /// Starts a suggestion request for `thread_id` and invalidates older requests. + /// + /// Side conversations deliberately stay silent because their composer has a + /// different placeholder contract. Starting a new request clears the current + /// visible candidate so the user never accepts text predicted from an older + /// completed boundary. + pub(super) fn request_next_prompt_suggestion_for_thread( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + ) { + if self.chat_widget.side_conversation_active() { + tracing::debug!(%thread_id, "skipping next prompt suggestion for side conversation"); + return; + } + if !self.chat_widget.can_show_next_prompt_suggestion() { + tracing::debug!(%thread_id, "skipping next prompt suggestion while composer is unavailable"); + return; + } + + self.cancel_pending_next_prompt_suggestion(); + self.chat_widget.clear_next_prompt_suggestion(); + self.next_prompt_suggestion_generation = self + .next_prompt_suggestion_generation + .saturating_add(/*rhs*/ 1); + let generation = self.next_prompt_suggestion_generation; + let request_handle = app_server.request_handle(); + let cancellation_token = format!("next-prompt-suggestion-{}", Uuid::new_v4()); + let cancel_request = NextPromptSuggestionCancelRequest { + request_handle: request_handle.clone(), + thread_id, + cancellation_token: cancellation_token.clone(), + }; + let app_event_tx = self.app_event_tx.clone(); + let task = tokio::spawn(async move { + let requested_at = Instant::now(); + let result = super::background_requests::fetch_next_prompt_suggestion( + request_handle, + thread_id, + cancellation_token, + ) + .await + .map_err(|err| format!("{err:#}")); + app_event_tx.send(AppEvent::NextPromptSuggestionReady { + generation, + thread_id, + latency_ms: u64::try_from(requested_at.elapsed().as_millis()).unwrap_or(u64::MAX), + result, + }); + }); + self.pending_next_prompt_suggestion = Some(PendingNextPromptSuggestion { + task, + cancel_request: Some(cancel_request), + }); + } + + /// Applies a completed request only when it still matches current UI state. + /// + /// Both the generation token and displayed thread must match. Ignoring stale + /// results here is the last guard against a slow background request replacing + /// a newer suggestion after the user typed, switched threads, or resumed a + /// different session. + pub(super) fn handle_next_prompt_suggestion_ready( + &mut self, + generation: u64, + thread_id: ThreadId, + latency_ms: u64, + result: Result, String>, + ) { + if generation != self.next_prompt_suggestion_generation + || self.current_displayed_thread_id() != Some(thread_id) + { + return; + } + self.pending_next_prompt_suggestion = None; + if !self.chat_widget.can_show_next_prompt_suggestion() { + tracing::debug!(%thread_id, "discarding next prompt suggestion while composer is unavailable"); + return; + } + match result { + Ok(suggestion) => { + tracing::debug!( + latency_ms = latency_ms, + has_suggestion = suggestion.is_some(), + "next prompt suggestion request finished" + ); + self.chat_widget.set_next_prompt_suggestion(suggestion); + } + Err(err) => tracing::debug!( + latency_ms = latency_ms, + error = %err, + "next prompt suggestion request failed" + ), + } + } + + /// Aborts pending generation without clearing the last visible suggestion. + /// + /// Input edits use this path so a user can type over the ghost text, clear the + /// draft, and still get the same already-produced suggestion back from + /// `ChatWidget`'s placeholder refresh. In-flight requests also get a + /// best-effort app-server cancellation so hidden sampling does not continue + /// after the UI has invalidated it. + pub(super) fn cancel_pending_next_prompt_suggestion(&mut self) { + if let Some(pending) = self.pending_next_prompt_suggestion.take() { + pending.task.abort(); + if let Some(cancel_request) = pending.cancel_request { + tokio::spawn(async move { + if let Err(err) = super::background_requests::cancel_next_prompt_suggestion( + cancel_request.request_handle, + cancel_request.thread_id, + cancel_request.cancellation_token, + ) + .await + { + tracing::debug!(error = %err, "next prompt suggestion cancellation failed"); + } + }); + } + } + self.next_prompt_suggestion_generation = self + .next_prompt_suggestion_generation + .saturating_add(/*rhs*/ 1); + } + + /// Moves the visible suggestion into editable composer text. + /// + /// Acceptance never submits the prompt. Returning `false` means there was no + /// currently stored suggestion to take. + pub(crate) fn accept_next_prompt_suggestion(&mut self) -> bool { + self.cancel_pending_next_prompt_suggestion(); + let Some(suggestion) = self.chat_widget.take_next_prompt_suggestion() else { + return false; + }; + self.chat_widget + .set_composer_text(suggestion, Vec::new(), Vec::new()); + true + } + + /// Returns whether this key event should accept the visible ghost text. + pub(crate) fn next_prompt_suggestion_key_should_accept(&self, key_event: KeyEvent) -> bool { + self.chat_widget.can_show_next_prompt_suggestion() + && self.chat_widget.has_next_prompt_suggestion() + && matches!( + key_event, + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } + ) + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index bf5ffdad36b2..193f36ae1971 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -411,6 +411,7 @@ impl App { self.thread_event_channels.clear(); self.agent_navigation.clear(); self.side_threads.clear(); + self.clear_next_prompt_suggestion(); self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; @@ -545,9 +546,11 @@ impl App { initial_user_message, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + let thread_id = started.session.thread_id; self.enqueue_primary_thread_session(started.session, started.turns) .await?; self.backfill_loaded_subagent_threads(app_server).await; + self.request_next_prompt_suggestion_for_thread(app_server, thread_id); Ok(()) } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9bbe0c60b478..d338f29689f2 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -60,6 +60,8 @@ pub(super) async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + next_prompt_suggestion_generation: 0, + pending_next_prompt_suggestion: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 04c8bbf9a060..71d04f07ba90 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3784,6 +3784,8 @@ async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + next_prompt_suggestion_generation: 0, + pending_next_prompt_suggestion: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } @@ -3847,6 +3849,8 @@ async fn make_test_app_with_channels() -> ( pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + next_prompt_suggestion_generation: 0, + pending_next_prompt_suggestion: None, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }, @@ -4118,6 +4122,136 @@ fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { }) } +#[tokio::test] +async fn tab_accepts_next_prompt_suggestion_without_submitting() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.chat_widget + .set_next_prompt_suggestion(Some("run the tests".to_string())); + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + + assert!(app.next_prompt_suggestion_key_should_accept(tab)); + assert!(app.accept_next_prompt_suggestion()); + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "run the tests" + ); + assert!( + op_rx.try_recv().is_err(), + "Tab acceptance should not submit" + ); +} + +#[tokio::test] +async fn tab_does_not_accept_before_next_prompt_suggestion_arrives() { + let (app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + + assert!(!app.next_prompt_suggestion_key_should_accept(tab)); + assert!( + op_rx.try_recv().is_err(), + "checking Tab acceptance should not submit" + ); +} + +#[tokio::test] +async fn visible_next_prompt_suggestion_returns_after_draft_is_cleared() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.chat_widget + .set_next_prompt_suggestion(Some("run the tests".to_string())); + app.chat_widget.apply_external_edit("x".to_string()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "x"); + assert_eq!( + app.chat_widget.next_prompt_suggestion(), + Some("run the tests") + ); + + app.chat_widget.apply_external_edit(String::new()); + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + + assert!(app.next_prompt_suggestion_key_should_accept(tab)); + assert!(app.accept_next_prompt_suggestion()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "run the tests" + ); + assert!( + op_rx.try_recv().is_err(), + "Tab acceptance should not submit" + ); +} + +#[tokio::test] +async fn unavailable_composer_discards_completed_next_prompt_suggestion() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.next_prompt_suggestion_generation = 1; + app.chat_widget.apply_external_edit("draft".to_string()); + + app.handle_next_prompt_suggestion_ready( + /*generation*/ 1, + thread_id, + /*latency_ms*/ 0, + Ok(Some("run the tests".to_string())), + ); + + assert_eq!(app.chat_widget.next_prompt_suggestion(), None); +} + +#[tokio::test] +async fn stale_next_prompt_suggestion_result_is_ignored() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.next_prompt_suggestion_generation = 2; + + app.handle_next_prompt_suggestion_ready( + /*generation*/ 1, + thread_id, + /*latency_ms*/ 0, + Ok(Some("run the tests".to_string())), + ); + + assert_eq!(app.chat_widget.next_prompt_suggestion(), None); +} + +#[tokio::test] +async fn typing_cancels_pending_next_prompt_suggestion_without_clearing_visible_suggestion() { + let mut app = make_test_app().await; + app.chat_widget + .set_next_prompt_suggestion(Some("run the tests".to_string())); + app.pending_next_prompt_suggestion = Some(PendingNextPromptSuggestion { + task: tokio::spawn(std::future::pending()), + cancel_request: None, + }); + + app.cancel_pending_next_prompt_suggestion(); + + assert!(app.pending_next_prompt_suggestion.is_none()); + assert_eq!( + app.chat_widget.next_prompt_suggestion(), + Some("run the tests") + ); +} + +#[tokio::test] +async fn thread_rollback_clears_visible_next_prompt_suggestion() { + let mut app = make_test_app().await; + app.chat_widget + .set_next_prompt_suggestion(Some("run the tests".to_string())); + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "old message".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc]; + + assert!(app.apply_non_pending_thread_rollback(/*num_turns*/ 1)); + + assert_eq!(app.chat_widget.next_prompt_suggestion(), None); +} + fn token_usage_notification( thread_id: ThreadId, turn_id: &str, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 8f15aed79a38..554300b0255e 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1441,6 +1441,17 @@ impl App { app_server: &mut AppServerSession, event: ThreadBufferedEvent, ) -> Result<()> { + let next_prompt_suggestion_action = match &event { + ThreadBufferedEvent::Notification(ServerNotification::TurnStarted(_)) => Some(false), + ThreadBufferedEvent::Notification(ServerNotification::TurnCompleted(notification)) => { + match notification.turn.status { + TurnStatus::Completed => Some(true), + TurnStatus::Interrupted | TurnStatus::Failed => Some(false), + TurnStatus::InProgress => None, + } + } + _ => None, + }; // Capture this before any potential thread switch: we only want to clear // the exit marker when the currently active thread acknowledges shutdown. let pending_shutdown_exit_completed = matches!( @@ -1492,6 +1503,11 @@ impl App { self.pending_shutdown_exit_thread_id = None; } self.handle_thread_event_now(event); + match next_prompt_suggestion_action { + Some(true) => self.request_next_prompt_suggestion(app_server), + Some(false) => self.clear_next_prompt_suggestion(), + None => {} + } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 625f8d0841b0..1f78d4a78db5 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -207,6 +207,7 @@ impl App { return; } + self.clear_next_prompt_suggestion(); let prefill = selection.prefill.clone(); let text_elements = selection.text_elements.clone(); let local_image_paths = selection.local_image_paths.clone(); @@ -510,6 +511,7 @@ impl App { if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } + self.clear_next_prompt_suggestion(); self.chat_widget .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 17b749971a2e..33cb69582661 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -158,6 +158,13 @@ pub(crate) enum AppEvent { thread_id: ThreadId, event: HistoryLookupResponse, }, + /// Delivers one background next-prompt RPC result back onto the UI thread. + NextPromptSuggestionReady { + generation: u64, + thread_id: ThreadId, + latency_ms: u64, + result: Result, String>, + }, /// Persist a submitted prompt in the cross-session message history. AppendMessageHistoryEntry { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1b24daac26c3..4e42d6b76b70 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -368,6 +368,7 @@ use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod model_popups; +mod next_prompt_suggestion; mod notifications; use self::notifications::Notification; mod permission_popups; @@ -619,6 +620,8 @@ pub(crate) struct ChatWidget { active_side_conversation: bool, normal_placeholder_text: String, side_placeholder_text: String, + /// Stored model suggestion used only as empty-composer ghost text. + next_prompt_suggestion: Option, forked_from: Option, interrupted_turn_notice_mode: InterruptedTurnNoticeMode, frame_requester: FrameRequester, @@ -1636,6 +1639,7 @@ impl ChatWidget { text_elements: Vec, local_image_paths: Vec, ) { + self.clear_next_prompt_suggestion(); self.bottom_pane .set_composer_text(text, text_elements, local_image_paths); self.refresh_plan_mode_nudge(); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index ef90c85d4c4c..1907fe38b24b 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -178,6 +178,7 @@ impl ChatWidget { active_side_conversation: false, normal_placeholder_text: placeholder, side_placeholder_text: side_placeholder, + next_prompt_suggestion: None, forked_from: None, interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, input_queue: InputQueueState::default(), diff --git a/codex-rs/tui/src/chatwidget/next_prompt_suggestion.rs b/codex-rs/tui/src/chatwidget/next_prompt_suggestion.rs new file mode 100644 index 000000000000..f877bcd2ed17 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/next_prompt_suggestion.rs @@ -0,0 +1,72 @@ +//! Projects stored next-prompt suggestions into the composer placeholder. +//! +//! `ChatWidget` owns only presentation state: the latest model text is +//! stored separately from the draft and becomes placeholder text only while the +//! composer is empty and the rest of the chat surface is idle. The app layer owns +//! generation, cancellation, and thread identity checks. + +use super::*; + +impl ChatWidget { + #[cfg_attr(not(test), allow(dead_code))] + /// Replaces the stored suggestion and refreshes placeholder visibility. + pub(crate) fn set_next_prompt_suggestion(&mut self, suggestion: Option) { + self.next_prompt_suggestion = suggestion; + self.refresh_composer_placeholder(); + } + + /// Removes the stored suggestion and reports whether anything changed. + pub(crate) fn clear_next_prompt_suggestion(&mut self) -> bool { + if self.next_prompt_suggestion.take().is_none() { + return false; + } + self.refresh_composer_placeholder(); + true + } + + /// Removes and returns the stored suggestion for composer acceptance. + pub(crate) fn take_next_prompt_suggestion(&mut self) -> Option { + let suggestion = self.next_prompt_suggestion.take()?; + self.refresh_composer_placeholder(); + Some(suggestion) + } + + #[cfg(test)] + pub(crate) fn next_prompt_suggestion(&self) -> Option<&str> { + self.next_prompt_suggestion.as_deref() + } + + /// Reports whether Tab acceptance has concrete suggestion text to move. + pub(crate) fn has_next_prompt_suggestion(&self) -> bool { + self.next_prompt_suggestion.is_some() + } + + /// Reports whether ghost text is allowed in the current composer surface. + /// + /// Suggestions are hidden while the user has a draft, a modal or popup owns + /// focus, a side conversation or plan mode changes composer semantics, a recent + /// error/rate-limit needs attention, or Codex is still responding. + pub(crate) fn can_show_next_prompt_suggestion(&self) -> bool { + self.bottom_pane.composer_is_empty() + && self.no_modal_or_popup_active() + && !self.active_side_conversation + && self.active_mode_kind() != ModeKind::Plan + && self.last_non_retry_error.is_none() + && self.codex_rate_limit_reached_type.is_none() + && !self.bottom_pane.is_task_running() + } + + /// Recomputes the visible placeholder from side, suggestion, and fallback state. + pub(crate) fn refresh_composer_placeholder(&mut self) { + let placeholder = if self.active_side_conversation { + self.side_placeholder_text.clone() + } else if self.can_show_next_prompt_suggestion() + && let Some(suggestion) = self.next_prompt_suggestion.as_ref() + { + suggestion.clone() + } else { + self.normal_placeholder_text.clone() + }; + self.bottom_pane.set_placeholder_text(placeholder); + } +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 45dbc362e996..c3c3fc92103a 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -436,6 +436,7 @@ impl ChatWidget { pub(super) fn refresh_plan_mode_nudge(&mut self) { self.bottom_pane .set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge()); + self.refresh_composer_placeholder(); } /// Hides the nudge for the current thread scope until the user changes conversation context. diff --git a/codex-rs/tui/src/chatwidget/side.rs b/codex-rs/tui/src/chatwidget/side.rs index e379fe360628..07c40c05e867 100644 --- a/codex-rs/tui/src/chatwidget/side.rs +++ b/codex-rs/tui/src/chatwidget/side.rs @@ -16,12 +16,7 @@ impl ChatWidget { pub(crate) fn set_side_conversation_active(&mut self, active: bool) { self.active_side_conversation = active; - let placeholder = if active { - self.side_placeholder_text.clone() - } else { - self.normal_placeholder_text.clone() - }; - self.bottom_pane.set_placeholder_text(placeholder); + self.refresh_composer_placeholder(); self.bottom_pane.set_side_conversation_active(active); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__next_prompt_suggestion_renders_as_empty_composer_placeholder.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__next_prompt_suggestion_renders_as_empty_composer_placeholder.snap new file mode 100644 index 000000000000..b5b67938b8bf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__next_prompt_suggestion_renders_as_empty_composer_placeholder.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/next_prompt_suggestion.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +" " +"› run the tests " +" " +" gpt-5.5 default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5d27e0ddc596..59f63b9b89c1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -227,6 +227,7 @@ mod guardian; mod helpers; mod history_replay; mod mcp_startup; +mod next_prompt_suggestion; mod permissions; mod plan_mode; mod popups_and_settings; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 63487f18b9e8..1555202a65be 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -185,6 +185,7 @@ pub(super) async fn make_chatwidget_manual( widget.normal_placeholder_text = "Ask Codex to do anything".to_string(); widget.side_placeholder_text = "Check recently modified functions for compatibility".to_string(); + widget.next_prompt_suggestion = None; widget .bottom_pane .set_placeholder_text(widget.normal_placeholder_text.clone()); diff --git a/codex-rs/tui/src/chatwidget/tests/next_prompt_suggestion.rs b/codex-rs/tui/src/chatwidget/tests/next_prompt_suggestion.rs new file mode 100644 index 000000000000..bf16969bbba3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/next_prompt_suggestion.rs @@ -0,0 +1,23 @@ +use super::helpers::make_chatwidget_manual; +use super::helpers::normalized_backend_snapshot; +use super::*; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +#[tokio::test] +async fn next_prompt_suggestion_renders_as_empty_composer_placeholder_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_next_prompt_suggestion(Some("run the tests".to_string())); + + let width = 80; + let height = chat.desired_height(width); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw next prompt suggestion placeholder"); + assert_chatwidget_snapshot!( + "next_prompt_suggestion_renders_as_empty_composer_placeholder", + normalized_backend_snapshot(terminal.backend()) + ); +}