From 334b6e7321d55697a2415a42e1725c814d3c0df4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 27 Sep 2026 05:19:09 +0000 Subject: [PATCH] Remove follow-up prompt suggestions from the TUI (#48621) ## What changed Remove automatic next-message generation after successful turns, suggestion rendering in the composer, and the associated Tab acceptance and Escape dismissal handling. Remove the `tui.prompt_suggestions` configuration option and its schema entry, along with suggestion-specific tests and snapshots. GitOrigin-RevId: 63bf6f2ca85da6ece502c86e2a805d000bf34210 --- codex-rs/config/src/types.rs | 4 - codex-rs/core/config.schema.json | 5 - codex-rs/core/src/config/config_tests.rs | 2 - codex-rs/core/src/config/mod.rs | 4 - codex-rs/thread-manager-sample/src/main.rs | 1 - codex-rs/tui/assets/prompt_suggestion.txt | 5 - codex-rs/tui/src/app.rs | 2 - codex-rs/tui/src/app/app_server_events.rs | 37 --- codex-rs/tui/src/app/config_persistence.rs | 5 - codex-rs/tui/src/app/event_dispatch.rs | 15 -- codex-rs/tui/src/app/input.rs | 1 - codex-rs/tui/src/app/prompt_suggestions.rs | 195 -------------- .../tui/src/app/prompt_suggestions_tests.rs | 241 ------------------ codex-rs/tui/src/app/reconnect.rs | 1 - codex-rs/tui/src/app/session_lifecycle.rs | 3 - ...ts__prompt_suggestion_request_history.snap | 27 -- codex-rs/tui/src/app/startup.rs | 2 - codex-rs/tui/src/app/test_support.rs | 1 - codex-rs/tui/src/app/tests.rs | 6 - codex-rs/tui/src/app/tests/buffered_replay.rs | 1 - .../tui/src/app/tests/fresh_sparkle_tests.rs | 1 - codex-rs/tui/src/app/tests/startup.rs | 6 - codex-rs/tui/src/app_backtrack.rs | 1 - codex-rs/tui/src/app_event.rs | 12 - codex-rs/tui/src/app_server_session.rs | 11 - .../src/app_server_session/rollout_history.rs | 1 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 76 +++--- .../chat_composer/composer_layout.rs | 6 +- .../chat_composer/footer_state_tests.rs | 34 --- .../chat_composer/keymap_bindings.rs | 66 ----- .../chat_composer/prompt_suggestions.rs | 107 -------- .../chat_composer/prompt_suggestions_tests.rs | 151 ----------- ...__tests__find_hides_prompt_suggestion.snap | 10 - .../src/bottom_pane/chat_composer/sparkle.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 24 -- ...composer__tests__prompt_suggestion_24.snap | 14 - codex-rs/tui/src/chatwidget.rs | 4 - codex-rs/tui/src/chatwidget/constructor.rs | 1 - codex-rs/tui/src/chatwidget/input_flow.rs | 3 - .../tui/src/chatwidget/input_submission.rs | 1 - .../tui/src/chatwidget/prompt_suggestions.rs | 60 ----- .../chatwidget/prompt_suggestions_tests.rs | 59 ----- codex-rs/tui/src/chatwidget/protocol.rs | 8 - codex-rs/tui/src/chatwidget/settings.rs | 1 - codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/local_settings.rs | 1 - codex-rs/tui/src/prompt_suggestions.rs | 51 ---- codex-rs/tui/src/prompt_suggestions_tests.rs | 28 -- 48 files changed, 49 insertions(+), 1249 deletions(-) delete mode 100644 codex-rs/tui/assets/prompt_suggestion.txt delete mode 100644 codex-rs/tui/src/app/prompt_suggestions.rs delete mode 100644 codex-rs/tui/src/app/prompt_suggestions_tests.rs delete mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__prompt_suggestions__tests__prompt_suggestion_request_history.snap delete mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/keymap_bindings.rs delete mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions.rs delete mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions_tests.rs delete mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_prompt_suggestion.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__prompt_suggestion_24.snap delete mode 100644 codex-rs/tui/src/chatwidget/prompt_suggestions.rs delete mode 100644 codex-rs/tui/src/chatwidget/prompt_suggestions_tests.rs delete mode 100644 codex-rs/tui/src/prompt_suggestions.rs delete mode 100644 codex-rs/tui/src/prompt_suggestions_tests.rs diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 81c953dc6f..e44f737c18 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -835,10 +835,6 @@ pub struct Tui { #[serde(default = "default_true")] pub auto_recap: bool, - /// Suggest a next message after successful turns. Defaults to `false`. - #[serde(default)] - pub prompt_suggestions: bool, - /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 15cc4e30cc..d908935343 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4627,11 +4627,6 @@ "default": "composer", "description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport." }, - "prompt_suggestions": { - "default": false, - "description": "Suggest a next message after successful turns. Defaults to `false`.", - "type": "boolean" - }, "question_esc_back": { "default": true, "description": "Escape returns from async questions to the composer, preserving the answer draft.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dbd3e4654a..0322db4978 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1264,7 +1264,6 @@ fn config_toml_deserializes_model_availability_nux() { show_tooltips: true, show_server_version_notice: true, auto_recap: true, - prompt_suggestions: false, disable_paste_burst: None, vim_mode_default: false, question_esc_back: true, @@ -4406,7 +4405,6 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { show_tooltips: true, show_server_version_notice: true, auto_recap: true, - prompt_suggestions: false, disable_paste_burst: None, vim_mode_default: false, question_esc_back: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 85e2383621..a782af1a50 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -773,9 +773,6 @@ pub struct Config { /// Generate automatic TUI recaps. Manual `/recap` remains available when disabled. pub tui_auto_recap: bool, - /// Generate suggested next messages in the TUI composer. - pub tui_prompt_suggestions: bool, - /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, @@ -4475,7 +4472,6 @@ impl Config { .map(|t| t.show_server_version_notice) .unwrap_or(true), tui_auto_recap: cfg.tui.as_ref().map(|t| t.auto_recap).unwrap_or(/*default*/ true), - tui_prompt_suggestions: cfg.tui.as_ref().is_some_and(|t| t.prompt_suggestions), model_availability_nux: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index ddbd85ba91..765315a11c 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -284,7 +284,6 @@ async fn new_config( show_tooltips: true, tui_show_server_version_notice: true, tui_auto_recap: true, - tui_prompt_suggestions: false, model_availability_nux: ModelAvailabilityNuxConfig::default(), tui_fullscreen_transcript: false, tui_copy_on_select: Default::default(), diff --git a/codex-rs/tui/assets/prompt_suggestion.txt b/codex-rs/tui/assets/prompt_suggestion.txt deleted file mode 100644 index b300c92ea2..0000000000 --- a/codex-rs/tui/assets/prompt_suggestion.txt +++ /dev/null @@ -1,5 +0,0 @@ -This is an app-generated request for a suggested next user message. The user did not write this message. - -Your suggestion will appear in the user's empty message composer. They can accept it, edit it, or ignore it; it will not be sent automatically. - -Predict this user's next message from the whole conversation. Before choosing a new content question, consider whether they want an operation on material already produced. The object may be the assistant's own answer, a draft, a list, a comparison, or an external artifact. They may want to reuse it in a different form, make it easier to read or copy, obtain more items of the same kind, retry an earlier response after discussing its flaw, or make one precise change. Choose such a move only when the user's purpose, prior reactions, or intended use supports it. Preserve the actual object and requested deliverable; an adjacent explanation or extra content is a different action. If no transformation is supported, choose the strongest grounded question, reply, or continuation. Keep the necessary linked requirements, without inventing observations or a new agenda. Set suggestion to exactly one natural message in the user's voice, one line, at most 240 characters. No alternatives or explanation. Prefer a grounded prediction; use null only if every concrete next move is arbitrary. \ No newline at end of file diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 25c6f2c732..506aefc10e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -240,7 +240,6 @@ mod permission_shortcuts; mod pets; mod platform_actions; mod plugin_mentions; -mod prompt_suggestions; mod rate_limit_refresh; mod realtime_delivery; mod realtime_settings; @@ -648,7 +647,6 @@ pub(crate) struct App { background_voice: Option>, background_voice_error: Option<(ThreadId, String)>, temporary_structured_requests: HashMap>, - hidden_prompt_threads: VecDeque, /// Track title generation across thread switches and deduplicate automatic requests. pending_thread_titles: HashMap<(ThreadId, ThreadTitleDestination), CancellationToken>, thread_event_listener_tasks: HashMap>, diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 35f9ae5d79..cf6e42e3d4 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -119,31 +119,6 @@ impl App { app_server_client: &AppServerSession, notification: ServerNotification, ) { - if let ServerNotification::ThreadStarted(started) = ¬ification - && started.thread.ephemeral - && matches!(started.thread.thread_source.as_ref(), Some(ThreadSource::Feature(source)) if source == "prompt_suggestion") - && let Ok(id) = ThreadId::from_string(&started.thread.id) - { - self.hidden_prompt_threads.push_back(id); - if self.hidden_prompt_threads.len() > 32 { - self.hidden_prompt_threads.pop_front(); - } - return; - } - if let ServerNotificationThreadTarget::Thread(id) = - server_notification_thread_target(¬ification) - && self.hidden_prompt_threads.contains(&id) - { - if let Some(sender) = self.temporary_structured_requests.get(&id) - && matches!( - ¬ification, - ServerNotification::ItemCompleted(_) | ServerNotification::TurnCompleted(_) - ) - { - let _ = sender.send(notification); - } - return; - } // A picker can leave an old runtime's close notification queued while the same thread // is resumed. Thread IDs survive reloads, so confirm that the displayed thread is still // unloaded before routing a close that would exit the TUI or switch away from it. @@ -542,18 +517,6 @@ impl App { app_server_client: &AppServerSession, request: ServerRequest, ) { - if server_request_thread_id(&request) - .is_some_and(|id| self.hidden_prompt_threads.contains(&id)) - { - let _ = self - .reject_app_server_request( - app_server_client, - request.id().clone(), - "Prompt suggestions cannot request user interaction".to_string(), - ) - .await; - return; - } if let ServerRequest::DynamicToolCall { request_id, params } = &request { if self.dynamic_tool_tasks.contains_key(request_id) || (params.namespace.as_deref() != Some(crate::dynamic_tools::NAMESPACE) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index ad7675361d..d04b74576d 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -418,11 +418,6 @@ impl App { .tui .terminal_resize_reflow_max_rows = self.local_settings.tui.terminal_resize_reflow_max_rows; - self.chat_widget.local_settings.tui.prompt_suggestions = - self.local_settings.tui.prompt_suggestions; - if !self.local_settings.tui.prompt_suggestions { - self.chat_widget.clear_prompt_suggestion(); - } self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 48c60cd129..9c1d3e474e 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -50,8 +50,6 @@ impl App { | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ResetTranscriptForThreadSwitchPreservingScreen | AppEvent::FinishPromptRevert { .. } - | AppEvent::PromptSuggestionStarted { .. } - | AppEvent::PromptSuggestionFinished { .. } | AppEvent::ManagedWorktreeCreated(_) | AppEvent::AgentsOverviewWorktreeCreated(_) | AppEvent::AppendMessageHistoryEntry { .. } @@ -2661,19 +2659,6 @@ impl App { self.suggest_thread_name(app_server, thread_id, request_id) .await; } - AppEvent::GeneratePromptSuggestion(request) => { - self.generate_prompt_suggestion(app_server, request); - } - AppEvent::PromptSuggestionStarted { request, result } => { - self.on_prompt_suggestion_started(app_server, request, result); - } - AppEvent::PromptSuggestionFinished { request, temporary_thread_id, text } => { - self.temporary_structured_requests.remove(&temporary_thread_id); - if text.is_none() { - request.cancellation.cancel(); - } - self.chat_widget.apply_prompt_suggestion(&request, text); - } AppEvent::ThreadTitleStarted { cancellation, thread_id, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 0df9cc7836..0ab8d14e06 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -728,7 +728,6 @@ impl App { pub(crate) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { !self.chat_widget.is_external_writer_view() - && !self.chat_widget.has_prompt_suggestion() && !self.chat_widget.side_conversation_active() && !self.chat_widget.shortcut_overlay_visible() && self.chat_widget.is_normal_backtrack_mode() diff --git a/codex-rs/tui/src/app/prompt_suggestions.rs b/codex-rs/tui/src/app/prompt_suggestions.rs deleted file mode 100644 index 40f6d4883f..0000000000 --- a/codex-rs/tui/src/app/prompt_suggestions.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Hidden fork requests. The composer owns request identity and cancellation, including -//! Escape during startup. A deadline cancels the result without dropping an in-flight fork. -use super::*; -use crate::prompt_suggestions::SuggestionRequest; -use crate::temporary_structured_request::collect_structured_response; -use crate::temporary_structured_request::unsubscribe_temporary_thread; -use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadForkParams; -use codex_app_server_protocol::ThreadForkResponse; -use codex_app_server_protocol::ThreadResumeParams; -use codex_app_server_protocol::ThreadResumeResponse; -use codex_app_server_protocol::ThreadSource; -use codex_app_server_protocol::TurnInterruptParams; -use codex_app_server_protocol::TurnInterruptResponse; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::UserInput; -use codex_context_fragments::AdditionalContextUserFragment; -use codex_context_fragments::ContextualUserFragment; -use codex_protocol::config_types::CollaborationMode; -use color_eyre::eyre::eyre; - -const SUGGESTION_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); - -impl App { - pub(super) fn generate_prompt_suggestion( - &mut self, - app_server: &AppServerSession, - request: SuggestionRequest, - ) { - if !self.local_settings.tui.prompt_suggestions - || request.cancellation.is_cancelled() - || self.chat_widget.thread_id() != Some(request.thread_id) - { - request.cancellation.cancel(); - return; - } - let cancellation = request.cancellation.clone(); - let finished = request.generation_finished.clone(); - tokio::spawn(async move { - tokio::select! { - _ = cancellation.cancelled() => {}, - _ = finished.cancelled() => {}, - _ = tokio::time::sleep(SUGGESTION_TIMEOUT) => cancellation.cancel(), - } - }); - let handle = app_server.request_handle(); - let events = self.app_event_tx.clone(); - tokio::spawn(async move { - // Await startup even after cancellation so the returned fork can be detached. - // Fork reloads config files; only exposed live settings can be preserved here. - let mut result = async { - let parent: ThreadResumeResponse = handle - .request_typed(ClientRequest::ThreadResume { - request_id: RequestId::String(format!("suggestion-resume-{}", request.id)), - params: ThreadResumeParams { - thread_id: request.thread_id.to_string(), - exclude_turns: true, - ..Default::default() - }, - }) - .await?; - if request.cancellation.is_cancelled() { - return Err(eyre!("suggestion cancelled")); - } - let mut config = std::collections::HashMap::from([( - "model_reasoning_summary".to_string(), - serde_json::to_value(request.summary)?, - )]); - if let Some(effort) = parent.reasoning_effort { - config.insert( - "model_reasoning_effort".to_string(), - serde_json::to_value(effort)?, - ); - } - let fork: ThreadForkResponse = handle - .request_typed(ClientRequest::ThreadFork { - request_id: RequestId::String(format!("suggestion-fork-{}", request.id)), - params: ThreadForkParams { - thread_id: request.thread_id.to_string(), - last_turn_id: Some(request.turn_id.clone()), - model: Some(parent.model), - model_provider: Some(parent.model_provider), - service_tier: Some(parent.service_tier), - runtime_workspace_roots: Some(parent.runtime_workspace_roots), - config: Some(config), - ephemeral: true, - exclude_turns: true, - thread_source: Some(ThreadSource::Feature( - "prompt_suggestion".to_string(), - )), - ..Default::default() - }, - }) - .await?; - color_eyre::eyre::Ok((fork.thread.id, parent.collaboration_mode)) - } - .await - .map_err(|error| error.to_string()); - if request.cancellation.is_cancelled() { - if let Ok((thread_id, _)) = result { - unsubscribe_temporary_thread(&handle, thread_id).await; - } - result = Err("suggestion cancelled".to_string()); - } - events.send(AppEvent::PromptSuggestionStarted { request, result }); - }); - } - - pub(super) fn on_prompt_suggestion_started( - &mut self, - app_server: &AppServerSession, - request: SuggestionRequest, - result: Result<(String, Option), String>, - ) { - let Ok((thread_id, collaboration_mode)) = result else { - request.generation_finished.cancel(); - request.cancellation.cancel(); - return; - }; - let handle = app_server.request_handle(); - let Ok(temporary_thread_id) = ThreadId::from_string(&thread_id) else { - request.generation_finished.cancel(); - request.cancellation.cancel(); - tokio::spawn(async move { - unsubscribe_temporary_thread(&handle, thread_id).await; - }); - return; - }; - let (sender, receiver) = mpsc::unbounded_channel(); - self.temporary_structured_requests - .insert(temporary_thread_id, sender); - let events = self.app_event_tx.clone(); - tokio::spawn(async move { - // Retain the inherited tools and settings. Await the turn ID before interrupting. - let mut turn_id = None; - let result = async { - if request.cancellation.is_cancelled() { - return Err(eyre!("suggestion cancelled")); - } - let response: TurnStartResponse = handle.request_typed(ClientRequest::TurnStart { - request_id: RequestId::String(format!("suggestion-turn-{}", Uuid::new_v4())), - params: TurnStartParams { - thread_id: thread_id.clone(), - collaboration_mode, - input: vec![UserInput::Text { - text: AdditionalContextUserFragment::new("prompt_suggestion".to_string(), format!("{}\n\nReturn only JSON matching this schema: {{\"type\":\"object\",\"properties\":{{\"suggestion\":{{\"type\":[\"string\",\"null\"]}}}},\"required\":[\"suggestion\"],\"additionalProperties\":false}}.", crate::prompt_suggestions::PROMPT)).render(), - text_elements: Vec::new(), - }], - ..Default::default() - }, - }).await?; - turn_id = Some(response.turn.id.clone()); - tokio::select! { - biased; - _ = request.cancellation.cancelled() => Err(eyre!("suggestion cancelled or timed out")), - result = collect_structured_response(receiver, &response.turn.id) => result, - } - }.await; - if result.is_err() - && let Some(turn_id) = turn_id - { - let _ = tokio::time::timeout( - SUGGESTION_TIMEOUT, - handle.request_typed::(ClientRequest::TurnInterrupt { - request_id: RequestId::String(format!( - "suggestion-interrupt-{}", - Uuid::new_v4() - )), - params: TurnInterruptParams { - thread_id: thread_id.clone(), - turn_id, - }, - }), - ) - .await; - } - unsubscribe_temporary_thread(&handle, thread_id).await; - let text = result - .ok() - .and_then(|text| crate::prompt_suggestions::parse_suggestion(&text)); - request.generation_finished.cancel(); - events.send(AppEvent::PromptSuggestionFinished { - request, - temporary_thread_id, - text, - }); - }); - } -} - -#[cfg(test)] -#[path = "prompt_suggestions_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/app/prompt_suggestions_tests.rs b/codex-rs/tui/src/app/prompt_suggestions_tests.rs deleted file mode 100644 index 194f9c15c5..0000000000 --- a/codex-rs/tui/src/app/prompt_suggestions_tests.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Tests hidden suggestion forks through the app-server API. - -use super::*; -use crate::app::session_lifecycle::ThreadAttachPresentation; -use crate::app::test_support::make_test_app; -use crate::app_event_sender::AppEventSender; -use codex_app_server_client::AppServerEvent; -use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::UserInput; -use core_test_support::context_snapshot; -use core_test_support::context_snapshot::ContextSnapshotOptions; -use core_test_support::responses; -use pretty_assertions::assert_eq; -use tempfile::tempdir; -use tokio::sync::mpsc::unbounded_channel; - -#[derive(Clone, Copy, PartialEq)] -enum Scenario { - Success, - UnchangedSettings, - CancelStartup, - Timeout, -} - -#[tokio::test] -async fn prompt_suggestion_fork_preserves_request_prefix_and_stays_hidden() -> color_eyre::Result<()> -{ - check_suggestion(Scenario::UnchangedSettings).await?; - check_suggestion(Scenario::Success).await -} - -#[tokio::test] -async fn prompt_suggestion_cancelled_startup_cleans_up_without_a_turn() -> color_eyre::Result<()> { - check_suggestion(Scenario::CancelStartup).await -} - -#[tokio::test] -async fn prompt_suggestion_timeout_interrupts_and_cleans_up() -> color_eyre::Result<()> { - check_suggestion(Scenario::Timeout).await -} - -async fn check_suggestion(scenario: Scenario) -> color_eyre::Result<()> { - let server = wiremock::MockServer::start().await; - let parent_response = responses::mount_sse_once( - &server, - responses::sse(vec![ - responses::ev_response_created("parent"), - responses::ev_assistant_message("answer", "Fixed the timeout."), - responses::ev_completed("parent"), - ]), - ) - .await; - let codex_home = tempdir()?; - let provider_id = "suggestion-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 = \"Suggestion 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.local_settings.tui.animations = false; - app.local_settings.tui.prompt_suggestions = true; - app.app_event_tx = AppEventSender::new(event_tx); - app.config = ConfigBuilder::default() - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(dunce::canonicalize(codex_home.path())?)) - .build() - .await?; - - 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_server - .thread_set_name(thread_id, "Timeout fix".into()) - .await?; - app.chat_widget - .on_thread_name_updated(thread_id, Some("Timeout fix".into())); - while event_rx.try_recv().is_ok() {} - let _: TurnStartResponse = app_server - .request_handle() - .request_typed(ClientRequest::TurnStart { - request_id: RequestId::String("parent-turn".into()), - params: TurnStartParams { - thread_id: thread_id.to_string(), - service_tier: (scenario == Scenario::Success).then(|| Some("priority".into())), - collaboration_mode: (scenario == Scenario::Success).then(|| { - codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model: "gpt-5.2".into(), - reasoning_effort: Some( - codex_protocol::openai_models::ReasoningEffort::High, - ), - developer_instructions: Some("Keep requests concise.".into()), - }, - } - }), - input: vec![UserInput::Text { - text: "Fix the timeout".into(), - text_elements: vec![], - }], - ..Default::default() - }, - }) - .await?; - let mut fork_response = None; - let mut hidden_id = None; - let mut interrupted = false; - tokio::time::timeout(Duration::from_secs(/*secs*/ 45), async { - loop { - tokio::select! { - Some(event) = event_rx.recv() => { - if matches!(&event, AppEvent::GeneratePromptSuggestion(_)) { - let mut response = responses::sse_response(responses::sse(vec![ - responses::ev_response_created("prediction"), - responses::ev_assistant_message("suggestion", r#"{"suggestion":"Add a regression test"}"#), - responses::ev_completed("prediction"), - ])); - if scenario == Scenario::Timeout { response = response.set_delay(Duration::from_secs(/*secs*/ 60)); } - fork_response = Some(responses::mount_response_once(&server, response).await); - } - if let AppEvent::PromptSuggestionStarted { result, request } = &event { - result.as_ref().expect("fork startup"); - if scenario == Scenario::CancelStartup { request.cancellation.cancel(); } - } - if let AppEvent::PromptSuggestionFinished { temporary_thread_id, .. } = &event { - hidden_id = Some(*temporary_thread_id); - } - app.handle_event(&mut tui, &mut app_server, event).await?; - - } - Some(event) = app_server.next_event() => { - if let AppServerEvent::ServerNotification(notification) = &event { - if let ServerNotification::TurnCompleted(completed) = notification.as_ref() - && completed.thread_id != thread_id.to_string() - { interrupted = completed.turn.status == codex_app_server_protocol::TurnStatus::Interrupted; } - if let ServerNotification::TurnStarted(started) = notification.as_ref() - && started.thread_id != thread_id.to_string() && scenario == Scenario::Timeout - { - tokio::time::pause(); - tokio::time::advance(Duration::from_secs(/*secs*/ 31)).await; - tokio::time::resume(); - } - } - app.handle_app_server_event(&app_server, event).await; - app.drain_active_thread_events_until(&mut tui, Instant::now() + Duration::from_secs(/*secs*/ 1)).await?; - } - } - if hidden_id.is_some() && (scenario != Scenario::Timeout || interrupted) { break; } - } - color_eyre::eyre::Ok(()) - }).await??; - let hidden_id = hidden_id.expect("hidden fork"); - assert!(!app.thread_event_channels.contains_key(&hidden_id)); - assert!(app.temporary_structured_requests.is_empty()); - let detached: codex_app_server_protocol::ThreadUnsubscribeResponse = app_server - .request_handle() - .request_typed(ClientRequest::ThreadUnsubscribe { - request_id: RequestId::String("verify-cleanup".into()), - params: codex_app_server_protocol::ThreadUnsubscribeParams { - thread_id: hidden_id.to_string(), - }, - }) - .await?; - assert!(matches!( - detached.status, - codex_app_server_protocol::ThreadUnsubscribeStatus::NotSubscribed - | codex_app_server_protocol::ThreadUnsubscribeStatus::NotLoaded - )); - if matches!(scenario, Scenario::CancelStartup | Scenario::Timeout) { - if scenario == Scenario::CancelStartup { - assert!(fork_response.as_ref().unwrap().requests().is_empty()); - } - assert!(!app.chat_widget.has_prompt_suggestion()); - return Ok(()); - } - let requests = [ - parent_response.single_request(), - fork_response - .expect("generation requested") - .single_request(), - ]; - if scenario == Scenario::Success { - insta::assert_snapshot!( - "prompt_suggestion_request_history", - context_snapshot::format_request_history_snapshot( - "A completed turn is forked for a hidden follow-up suggestion with the parent's request prefix intact.", - &requests, - &ContextSnapshotOptions::default().rewrite_known_segments(), - ) - ); - } - let parent = requests[0].body_json(); - let fork = requests[1].body_json(); - for field in [ - "model", - "instructions", - "tools", - "reasoning", - "service_tier", - "text", - "prompt_cache_key", - "stream_options", - ] { - assert_eq!(&fork[field], &parent[field], "{field}"); - } - let parent_input = parent["input"].as_array().expect("parent input"); - let fork_input = fork["input"].as_array().expect("fork input"); - assert_eq!(&fork_input[..parent_input.len()], parent_input.as_slice()); - assert!(app.chat_widget.composer_is_empty()); - assert!( - crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) - .contains("Add a regression test") - ); - Ok(()) -} diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index a68ae3916b..bf07b73720 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -175,7 +175,6 @@ impl App { } pub(super) fn begin_reconnect(&mut self) -> bool { - self.chat_widget.clear_prompt_suggestion(); if matches!(self.app_server_target, AppServerTarget::Embedded) { return false; } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 879c20cef0..0b0089f0c8 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -490,7 +490,6 @@ impl App { /// This helper copies every known nickname/role from `AgentNavigationState` into the /// replacement widget so that replayed collab items render agent names immediately. pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { - self.chat_widget.clear_prompt_suggestion(); self.pending_right_click_paste = None; if !self.chat_widget.realtime_conversation_is_running() { self.retain_realtime_replay_state_before_replace(); @@ -848,7 +847,6 @@ impl App { .set_queue_submissions_until_session_configured(/*queue*/ false); match result { Ok(started) => { - self.chat_widget.prompt_suggestion_summary = started.reasoning_summary; self.chat_widget.mark_fresh_task_for_sparkle(&started); let thread_id = started.session.thread_id; if started.task_tools_available { @@ -1091,7 +1089,6 @@ impl App { initial_user_message, ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.chat_widget.prompt_suggestion_summary = started.reasoning_summary; if matches!( presentation, ThreadAttachPresentation::Fresh | ThreadAttachPresentation::FreshWithDraft diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__prompt_suggestions__tests__prompt_suggestion_request_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__prompt_suggestions__tests__prompt_suggestion_request_history.snap deleted file mode 100644 index 56079d5be7..0000000000 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__prompt_suggestions__tests__prompt_suggestion_request_history.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/app/prompt_suggestions_tests.rs -expression: "context_snapshot::format_request_history_snapshot(\"A completed turn is forked for a hidden follow-up suggestion with the parent's request prefix intact.\",\n&requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)" ---- -Scenario: A completed turn is forked for a hidden follow-up suggestion with the parent's request prefix intact. - -## Window 1 --- request 1 (turn) -- -00:message/developer[3]: - [01] - [02] - [03] -01:message/user: - -02:message/user: - Fix the timeout --- request 2 (turn) -- -03:message/assistant: - Fixed the timeout. -04:message/user: - This is an app-generated request for a suggested next user message. The user did not write this message. - - Your suggestion will appear in the user's empty message composer. They can accept it, edit it, or ignore it; it will not be sent automatically. - - Predict this user's next message from the whole conversation. Before choosing a new content question, co...e null only if every concrete next move is arbitrary. [hash=08478ce37447c028] - - Return only JSON matching this schema: {"type":"object","properties":{"suggestion":{"type":["string","nu...ionalProperties":false}. [hash=18d5da5f1ee1d605] diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 6b283327a7..f9da39c26c 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -813,7 +813,6 @@ See the Codex keymap documentation for supported actions and examples." background_voice: None, background_voice_error: None, temporary_structured_requests: HashMap::new(), - hidden_prompt_threads: VecDeque::new(), pending_thread_titles: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -876,7 +875,6 @@ See the Codex keymap documentation for supported actions and examples." app.update_visible_history_rows(tui.terminal.last_known_screen_size); let initial_session_started_at = Instant::now(); if let Some(started) = initial_started_thread { - app.chat_widget.prompt_suggestion_summary = started.reasoning_summary; let thread_id = started.session.thread_id; app.chat_widget .set_task_mentions_enabled(started.task_tools_available); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index c88edb3b50..92b07cd359 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -84,7 +84,6 @@ pub(crate) async fn make_test_app() -> App { background_voice: None, background_voice_error: None, temporary_structured_requests: HashMap::new(), - hidden_prompt_threads: VecDeque::new(), pending_thread_titles: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8fd01e8a85..6710c2dd19 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -630,7 +630,6 @@ async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }, session_lifecycle::ThreadAttachPresentation::SessionLineage, /*initial_user_message*/ None, @@ -5307,7 +5306,6 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() { turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }, &mut child_snapshot, ) @@ -6018,7 +6016,6 @@ async fn make_test_app() -> Box { background_voice: None, background_voice_error: None, temporary_structured_requests: HashMap::new(), - hidden_prompt_threads: VecDeque::new(), pending_thread_titles: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -6134,7 +6131,6 @@ pub(super) async fn make_test_app_with_channels() -> ( background_voice: None, background_voice_error: None, temporary_structured_requests: HashMap::new(), - hidden_prompt_threads: VecDeque::new(), pending_thread_titles: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -6463,7 +6459,6 @@ async fn app_server_thread_replacement_clears_previous_transcript_before_replay( )], blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }, session_lifecycle::ThreadAttachPresentation::SessionLineage, /*initial_user_message*/ None, @@ -8830,7 +8825,6 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { turns: resumed_turns.clone(), blocks_direct_input: true, task_tools_available: false, - reasoning_summary: None, }, &mut snapshot, ) diff --git a/codex-rs/tui/src/app/tests/buffered_replay.rs b/codex-rs/tui/src/app/tests/buffered_replay.rs index a9aeed4df2..708efcacb6 100644 --- a/codex-rs/tui/src/app/tests/buffered_replay.rs +++ b/codex-rs/tui/src/app/tests/buffered_replay.rs @@ -103,7 +103,6 @@ async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary() turns: vec![turn], blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }, &mut snapshot, ) diff --git a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs index 745d538412..714e9bd22b 100644 --- a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs +++ b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs @@ -16,7 +16,6 @@ fn started(model: &str) -> AppServerStartedThread { turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, } } diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 33c1204e93..c5f43238be 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -921,7 +921,6 @@ async fn fresh_startup_thread_drains_buffered_approval_before_draft_handoff() -> turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }), }, )) @@ -1137,7 +1136,6 @@ async fn startup_thread_started_submits_queued_startup_input() { turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }), ) .await @@ -1176,7 +1174,6 @@ async fn fresh_startup_notice_follows_session_attachment() { turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }), ) .await @@ -1336,7 +1333,6 @@ async fn startup_thread_started_discards_another_threads_buffered_events() { turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }), ) .await @@ -1386,7 +1382,6 @@ async fn startup_thread_started_does_not_replay_resolved_approval() -> Result<() turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }), ) .await?; @@ -1529,7 +1524,6 @@ fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { turns: Vec::new(), blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }), ) .await?; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index cc2c5513eb..4e63f0caef 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -122,7 +122,6 @@ impl App { /// Revert the current thread before the selected prompt. pub(crate) fn apply_backtrack_selection(&mut self, selection: BacktrackSelection) { - self.chat_widget.clear_prompt_suggestion(); if self.chat_widget.side_conversation_active() { self.reset_backtrack_state(); self.chat_widget diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d6156d664..982c3ecb5a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -61,7 +61,6 @@ use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; use codex_plugin::PluginCapabilitySummary; -use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::models::ActivePermissionProfile; use codex_realtime_webrtc::StartedRealtimeWebrtcSession; @@ -314,17 +313,6 @@ pub(crate) enum AppEvent { thread_id: ThreadId, request_id: Uuid, }, - /// Generate a next-message suggestion for one live completed turn. - GeneratePromptSuggestion(crate::prompt_suggestions::SuggestionRequest), - PromptSuggestionStarted { - request: crate::prompt_suggestions::SuggestionRequest, - result: Result<(String, Option), String>, - }, - PromptSuggestionFinished { - request: crate::prompt_suggestions::SuggestionRequest, - temporary_thread_id: ThreadId, - text: Option, - }, /// Register a hidden title-generation thread started in the background. ThreadTitleStarted { cancellation: CancellationToken, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 3bfea5b4c4..01b3d347c7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -364,7 +364,6 @@ impl ThreadParamsMode { #[derive(Debug, Clone)] pub(crate) struct AppServerStartedThread { - pub(crate) reasoning_summary: Option, pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, pub(crate) blocks_direct_input: bool, @@ -2202,14 +2201,6 @@ async fn started_thread_from_start_response( turns: response.thread.turns, blocks_direct_input, task_tools_available: false, - reasoning_summary: match thread_params_mode { - ThreadParamsMode::Embedded => Some( - config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::None), - ), - ThreadParamsMode::Remote => config.model_reasoning_summary, - }, }) } @@ -2233,7 +2224,6 @@ async fn started_thread_from_resume_response( turns: response.thread.turns, blocks_direct_input, task_tools_available: false, - reasoning_summary: None, }) } @@ -2257,7 +2247,6 @@ async fn started_thread_from_fork_response( turns: response.thread.turns, blocks_direct_input, task_tools_available: false, - reasoning_summary: None, }) } diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index ff984cb219..5a86ee0260 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -103,7 +103,6 @@ impl AppServerSession { turns: thread.turns, blocks_direct_input: false, task_tools_available: false, - reasoning_summary: None, }, history_notice, )) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ff4e78b37a..625c614920 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -74,11 +74,6 @@ //! - Local in-session history (full text + text elements + local/remote image attachments). //! //! Plain-text history recall strips images and their placeholders. -//! -//! Follow-up suggestions live outside the draft. Empty, focused, editable composers show -//! them dimly; Tab copies one into the draft and a separate Enter submits. Escape cancels -//! even a pending suggestion. Popups, Vim Escape, attachments, and paste bursts take precedence. -//! Interactive transcript footers hide the suggestion and its reserved height until editing resumes. //! When recalling a persistent entry, encoded task links restore atomic elements and bindings. //! Recall moves the cursor to the end. Question editors copy primary history on recall/search; //! draft capture cancels previews, and restoration resets traversal. @@ -386,13 +381,11 @@ mod draft_state; mod footer_state; mod history_search; mod inline_input; -mod keymap_bindings; mod mouse; mod paste_input; mod popup_state; mod reconnect; pub(crate) use reconnect::RestrictedInputMode; -mod prompt_suggestions; mod slash_input; mod sparkle; mod status_surface; @@ -624,9 +617,6 @@ pub(crate) struct ChatComposer { luna_reserve_active: bool, attachments: AttachmentState, placeholder_text: String, - prompt_suggestion: Option, - suggestion_tab_reserved: KeymapContextSet, - suggestion_tab_accepted: bool, blocks_direct_input: bool, is_task_running: bool, queue_submissions: bool, @@ -803,9 +793,6 @@ impl ChatComposer { luna_reserve_active: false, attachments: AttachmentState::default(), placeholder_text, - prompt_suggestion: None, - suggestion_tab_reserved: KeymapContextSet::default(), - suggestion_tab_accepted: false, blocks_direct_input: false, is_task_running: false, queue_submissions: false, @@ -1040,6 +1027,51 @@ impl ChatComposer { self.app_event_tx = app_event_tx; } + /// Replace composer, editor, and footer-hint key bindings from one runtime snapshot. + /// + /// Submit and queue bindings are cached here because composer dispatch must + /// check them before generic textarea editing. The embedded textarea receives + /// the same snapshot's editor bindings so a live remap cannot leave submit + /// keys updated while cursor/editing keys still use old defaults. + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.submit_keys = keymap.composer.submit.clone(); + self.queue_keys = keymap.composer.queue.clone(); + self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone(); + self.history_search_previous_keys = keymap.composer.history_search_previous.clone(); + self.history_search_next_keys = keymap.composer.history_search_next.clone(); + self.editor_keymap = keymap.editor.clone(); + self.vim_normal_keymap = keymap.vim_normal.clone(); + self.draft.textarea.set_keymap_bindings(keymap); + self.footer.external_editor_key = + keymap.primary_hint(KeymapContext::Global, "open_external_editor"); + self.footer.show_warnings_key = keymap.primary_hint(KeymapContext::Global, "open_warnings"); + self.footer.show_transcript_key = + keymap.primary_hint(KeymapContext::Global, "open_transcript"); + self.footer.find_transcript_key = + keymap.primary_hint(KeymapContext::Global, "find_transcript"); + self.footer.focus_activity_key = + keymap.primary_hint(KeymapContext::Global, "focus_activity"); + self.footer.insert_newline_key = + match keymap.primary_hint(KeymapContext::Editor, "insert_newline") { + hint @ Some(ShortcutHint::Chord { .. }) => hint, + _ => footer_insert_newline_key( + &keymap.editor.insert_newline, + self.footer.use_shift_enter_hint, + ) + .map(ShortcutHint::from), + }; + self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue"); + self.footer.toggle_shortcuts_key = + keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts"); + self.footer.history_search_key = + keymap.primary_hint(KeymapContext::Composer, "history_search_previous"); + self.footer.reasoning_down_key = + keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"); + self.footer.reasoning_up_key = + keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"); + self.footer.toggle_voice_key = keymap.primary_hint(KeymapContext::Chat, "toggle_voice"); + } + /// Return the contexts whose handlers can consume the next composer key. pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { if self.draft.textarea.vim_query().is_some() { @@ -1922,12 +1954,6 @@ impl ChatComposer { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - self.suggestion_tab_accepted &= key_event.code == KeyCode::Tab - && key_event.modifiers == KeyModifiers::NONE - && key_event.kind != KeyEventKind::Release; - if self.suggestion_tab_accepted { - return (InputResult::None, false); - } if !self.draft.input_enabled { return (InputResult::None, false); } @@ -3483,9 +3509,6 @@ impl ChatComposer { self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } - if self.handle_prompt_suggestion_key(key_event) { - return (InputResult::None, true); - } if key_event.code == KeyCode::Esc { if self.is_empty() { let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); @@ -4349,9 +4372,6 @@ impl ChatComposer { } pub fn set_task_running(&mut self, running: bool) { - if running { - self.clear_prompt_suggestion(); - } self.is_task_running = running; } @@ -4974,11 +4994,9 @@ impl ChatComposer { } } } - if let Some(lines) = self.prompt_suggestion_lines(textarea_rect.width, options) { - Paragraph::new(lines).render(textarea_rect, buf); - } else if !self.draft.input_enabled || textarea_is_empty { + if !self.draft.input_enabled || textarea_is_empty { let text = if self.draft.input_enabled { - self.placeholder_text.to_string() + self.placeholder_text.as_str().to_string() } else { self.draft .input_disabled_placeholder diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs index 148afb44ef..b79c28b98a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs @@ -183,11 +183,7 @@ impl ChatComposer { .try_into() .unwrap_or(u16::MAX); let remote_images_separator = u16::from(remote_images_height > 0); - self.prompt_suggestion_lines(inner_width, options) - .map_or_else( - || self.draft.textarea.desired_height(inner_width), - |lines| u16::try_from(lines.len()).unwrap_or(u16::MAX), - ) + self.draft.textarea.desired_height(inner_width) + remote_images_height + remote_images_separator + self.status_surface_height(options) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs index e041a0b36c..5db883b7e6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs @@ -104,40 +104,6 @@ fn retained_command_suggestions_yield_paint_and_cursor_to_find() { assert_eq!(render(&composer, /*footer*/ None), original); } -#[test] -fn prompt_suggestion_yields_space_to_transcript_find() { - let mut composer = composer(); - let footer = TranscriptFooter { - text: vec![ - Line::from("Find: needle"), - Line::from("Enter next · Esc close"), - ] - .into(), - cursor_column: Some(8), - is_interactive: true, - }; - let without_suggestion = render(&composer, Some(&footer)); - let request = crate::prompt_suggestions::SuggestionRequest { - thread_id: codex_protocol::ThreadId::new(), - turn_id: "turn".into(), - summary: codex_protocol::config_types::ReasoningSummary::None, - id: uuid::Uuid::new_v4(), - cancellation: tokio_util::sync::CancellationToken::new(), - generation_finished: tokio_util::sync::CancellationToken::new(), - }; - composer.set_prompt_suggestion(request.clone()); - composer.apply_prompt_suggestion( - &request, - Some("Add a regression test for the timeout and explain how the cleanup works".into()), - ); - let original = render(&composer, /*footer*/ None); - let during_search = render(&composer, Some(&footer)); - assert_eq!(during_search, without_suggestion); - insta::assert_snapshot!("find_hides_prompt_suggestion", text(&during_search.0)); - assert_eq!(render(&composer, /*footer*/ None), original); - assert!(!request.cancellation.is_cancelled()); -} - #[test] fn copying_from_find_preserves_the_query_above_selection_feedback() { let mut composer = composer(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/keymap_bindings.rs b/codex-rs/tui/src/bottom_pane/chat_composer/keymap_bindings.rs deleted file mode 100644 index 13bf1d0936..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/keymap_bindings.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Apply key bindings together so editing, submission, and suggestion acceptance agree. - -use super::*; - -impl ChatComposer { - /// Replace composer, editor, and footer-hint key bindings from one runtime snapshot. - /// - /// Submit and queue bindings are cached here because composer dispatch must - /// check them before generic textarea editing. The embedded textarea receives - /// the same snapshot's editor bindings so a live remap cannot leave submit - /// keys updated while cursor/editing keys still use old defaults. - pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { - self.suggestion_tab_reserved = crate::keymap::keymap_action_ids() - .filter(|action| { - matches!( - action.context, - KeymapContext::Editor | KeymapContext::VimNormal | KeymapContext::Composer - ) && !(action.context == KeymapContext::Composer && action.action == "queue") - && crate::keymap::bindings_for_action( - keymap, - action.context.config_name(), - action.action, - ) - .is_some_and(|bindings| bindings.is_pressed(KeyCode::Tab.into())) - }) - .fold(KeymapContextSet::default(), |contexts, action| { - contexts.with(action.context) - }); - self.submit_keys = keymap.composer.submit.clone(); - self.queue_keys = keymap.composer.queue.clone(); - self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone(); - self.history_search_previous_keys = keymap.composer.history_search_previous.clone(); - self.history_search_next_keys = keymap.composer.history_search_next.clone(); - self.editor_keymap = keymap.editor.clone(); - self.vim_normal_keymap = keymap.vim_normal.clone(); - self.draft.textarea.set_keymap_bindings(keymap); - self.footer.external_editor_key = - keymap.primary_hint(KeymapContext::Global, "open_external_editor"); - self.footer.show_warnings_key = keymap.primary_hint(KeymapContext::Global, "open_warnings"); - self.footer.show_transcript_key = - keymap.primary_hint(KeymapContext::Global, "open_transcript"); - self.footer.find_transcript_key = - keymap.primary_hint(KeymapContext::Global, "find_transcript"); - self.footer.focus_activity_key = - keymap.primary_hint(KeymapContext::Global, "focus_activity"); - self.footer.insert_newline_key = - match keymap.primary_hint(KeymapContext::Editor, "insert_newline") { - hint @ Some(ShortcutHint::Chord { .. }) => hint, - _ => footer_insert_newline_key( - &keymap.editor.insert_newline, - self.footer.use_shift_enter_hint, - ) - .map(ShortcutHint::from), - }; - self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue"); - self.footer.toggle_shortcuts_key = - keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts"); - self.footer.history_search_key = - keymap.primary_hint(KeymapContext::Composer, "history_search_previous"); - self.footer.reasoning_down_key = - keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"); - self.footer.reasoning_up_key = - keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"); - self.footer.toggle_voice_key = keymap.primary_hint(KeymapContext::Chat, "toggle_voice"); - } -} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions.rs b/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions.rs deleted file mode 100644 index 034f4e3c90..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Keep suggestions outside the draft until Tab accepts them. Typing hides them, -//! Escape cancels them, and stale or empty results cannot capture input. -//! Transcript interactions hide suggestion text and its reserved height without cancelling it. - -use super::*; -use crate::prompt_suggestions::PromptSuggestion; -use crate::prompt_suggestions::SuggestionRequest; - -impl ChatComposer { - pub(crate) fn set_prompt_suggestion(&mut self, request: SuggestionRequest) { - self.prompt_suggestion = Some(PromptSuggestion { - request, - text: None, - }); - } - - pub(crate) fn has_prompt_suggestion(&self) -> bool { - self.prompt_suggestion - .as_ref() - .is_some_and(|suggestion| !suggestion.request.cancellation.is_cancelled()) - } - - pub(crate) fn clear_prompt_suggestion(&mut self) { - self.prompt_suggestion = None; - } - - pub(crate) fn apply_prompt_suggestion( - &mut self, - request: &SuggestionRequest, - text: Option, - ) { - if let Some(suggestion) = &mut self.prompt_suggestion - && suggestion.request.id == request.id - && !request.cancellation.is_cancelled() - { - suggestion.text = text; - if suggestion.text.is_none() { - self.clear_prompt_suggestion(); - } - } - } - - pub(super) fn visible_prompt_suggestion(&self) -> Option<&str> { - let suggestion = self.prompt_suggestion.as_ref()?; - (self.has_focus - && self.sparkle.terminal_focused - && self.draft.input_enabled - && !self.blocks_direct_input - && !self.is_task_running - && !self.queue_submissions - && self.is_empty() - && !self.popup_active() - && !self.is_in_paste_burst() - && !suggestion.request.cancellation.is_cancelled()) - .then_some(suggestion.text.as_deref()) - .flatten() - } - - pub(super) fn prompt_suggestion_lines( - &self, - width: u16, - options: ComposerRenderOptions<'_>, - ) -> Option>> { - if options.footer.is_some_and(|footer| footer.is_interactive) { - return None; - } - Some( - textwrap::wrap(self.visible_prompt_suggestion()?, usize::from(width.max(1))) - .into_iter() - .map(|line| Line::from(line.into_owned().dim())) - .collect(), - ) - } - - pub(super) fn handle_prompt_suggestion_key(&mut self, key: KeyEvent) -> bool { - if key.modifiers != KeyModifiers::NONE { - return false; - } - if key.code == KeyCode::Esc && self.has_prompt_suggestion() { - self.clear_prompt_suggestion(); - return true; - } - if key.code == KeyCode::Tab - && ![ - KeymapContext::Editor, - KeymapContext::VimNormal, - KeymapContext::Composer, - ] - .into_iter() - .any(|context| { - self.suggestion_tab_reserved.contains(context) - && self.keymap_contexts().contains(context) - }) - && let Some(text) = self.visible_prompt_suggestion().map(str::to_owned) - { - self.clear_prompt_suggestion(); - self.insert_str(&text); - self.suggestion_tab_accepted = true; - return true; - } - false - } -} - -#[cfg(test)] -#[path = "prompt_suggestions_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions_tests.rs deleted file mode 100644 index b32a8d324e..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions_tests.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Exercise suggestion parsing and composer input behavior. - -use super::super::tests::new_test_composer; -use super::super::tests::snapshot_composer_state_with_width; -use super::*; -use codex_protocol::ThreadId; -use pretty_assertions::assert_eq; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -fn request() -> SuggestionRequest { - SuggestionRequest { - thread_id: ThreadId::new(), - turn_id: "turn".into(), - summary: codex_protocol::config_types::ReasoningSummary::None, - id: Uuid::new_v4(), - cancellation: CancellationToken::new(), - generation_finished: CancellationToken::new(), - } -} - -#[test] -fn prompt_suggestion_tab_edits_and_enter_submits() { - let (mut composer, _rx) = new_test_composer(); - composer.set_vim_enabled(/*enabled*/ true); - composer.handle_key_event(KeyCode::Esc.into()); - let request = request(); - composer.set_prompt_suggestion(request.clone()); - composer.apply_prompt_suggestion(&request, Some("Add a regression test".into())); - assert!(composer.is_empty()); - let (result, _) = composer.handle_key_event(KeyCode::Tab.into()); - assert!(matches!(result, InputResult::None)); - assert_eq!(composer.draft.textarea.text(), "Add a regression test"); - assert!(request.cancellation.is_cancelled()); - for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { - let (result, _) = composer.handle_key_event(KeyEvent::new_with_kind( - KeyCode::Tab, - KeyModifiers::NONE, - kind, - )); - assert!(matches!(result, InputResult::None)); - assert_eq!(composer.draft.textarea.text(), "Add a regression test"); - } - composer.handle_key_event(KeyCode::Char('u').into()); - assert!(composer.is_empty()); - composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(composer.draft.textarea.text(), "Add a regression test"); - let (result, _) = composer.handle_key_event(KeyCode::Enter.into()); - assert!( - matches!(result, InputResult::Submitted { text, .. } if text == "Add a regression test") - ); -} - -#[test] -fn prompt_suggestion_dismissal_and_stale_result() { - let (mut composer, _rx) = new_test_composer(); - let old = request(); - composer.set_prompt_suggestion(old.clone()); - composer.handle_key_event(KeyCode::Esc.into()); - assert!(old.cancellation.is_cancelled()); - composer.apply_prompt_suggestion(&old, Some("Late".into())); - assert_eq!(composer.visible_prompt_suggestion(), None); - let next = request(); - composer.set_prompt_suggestion(next.clone()); - composer.apply_prompt_suggestion(&next, Some("Next".into())); - composer.apply_prompt_suggestion(&old, Some("Stale".into())); - assert_eq!(composer.visible_prompt_suggestion(), Some("Next")); - composer.set_task_running(/*running*/ true); - assert!(next.cancellation.is_cancelled()); - composer.set_prompt_suggestion(request()); - let empty = composer.prompt_suggestion.as_ref().unwrap().request.clone(); - composer.apply_prompt_suggestion(&empty, /*text*/ None); - assert!(!composer.has_prompt_suggestion()); -} - -#[test] -fn prompt_suggestion_hides_for_typing_and_input_owners() { - let (mut composer, _rx) = new_test_composer(); - let request = request(); - composer.set_prompt_suggestion(request.clone()); - composer.handle_key_event(KeyCode::Char('a').into()); - composer.apply_prompt_suggestion(&request, Some("Continue".into())); - assert_eq!(composer.visible_prompt_suggestion(), None); - composer.handle_key_event(KeyCode::Tab.into()); - assert!(!composer.draft.textarea.text().contains("Continue")); - composer.draft.paste_burst.clear_after_explicit_paste(); - composer.draft.textarea.set_text_clearing_elements(""); - assert_eq!(composer.visible_prompt_suggestion(), Some("Continue")); - composer - .attachments - .remote_image_urls - .push("https://example.test/image.png".into()); - assert_eq!(composer.visible_prompt_suggestion(), None); -} - -#[test] -fn prompt_suggestion_snapshots() { - snapshot_composer_state_with_width( - "prompt_suggestion_24", - /*width*/ 24, - /*enhanced_keys_supported*/ false, - |composer| { - let request = request(); - composer.set_prompt_suggestion(request.clone()); - composer.apply_prompt_suggestion( - &request, - Some("Add a regression test for the timeout".into()), - ); - }, - ); -} - -#[test] -fn prompt_suggestion_yields_to_remapped_tab_and_popup() { - let (mut composer, _rx) = new_test_composer(); - let request = request(); - composer.set_prompt_suggestion(request.clone()); - composer.apply_prompt_suggestion(&request, Some("Continue".into())); - let mut config = codex_config::types::TuiKeymap::default(); - config.composer.submit = Some(codex_config::types::KeybindingsSpec::One( - codex_config::types::KeybindingSpec("tab".into()), - )); - config.composer.queue = Some(codex_config::types::KeybindingsSpec::Many(vec![])); - let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("keymap"); - composer.set_keymap_bindings(&keymap); - composer.handle_key_event(KeyCode::Tab.into()); - assert!(composer.is_empty()); - assert!(!request.cancellation.is_cancelled()); - composer.handle_paste("/".into()); - assert!(composer.popup_active()); - composer.handle_key_event(KeyCode::Esc.into()); - assert!(!request.cancellation.is_cancelled()); -} - -#[test] -fn prompt_suggestion_tab_only_yields_to_active_vim_bindings() { - let (mut composer, _rx) = new_test_composer(); - composer.set_vim_enabled(/*enabled*/ true); - composer.handle_key_event(KeyCode::Esc.into()); - let request = request(); - composer.set_prompt_suggestion(request.clone()); - composer.apply_prompt_suggestion(&request, Some("Continue".into())); - let mut keymap = crate::keymap::RuntimeKeymap::defaults(); - keymap.vim_normal.undo = vec![crate::key_hint::plain(KeyCode::Tab)]; - composer.set_keymap_bindings(&keymap); - composer.handle_key_event(KeyCode::Tab.into()); - assert!(composer.is_empty()); - composer.handle_key_event(KeyCode::Char('i').into()); - composer.handle_key_event(KeyCode::Tab.into()); - assert_eq!(composer.current_text(), "Continue"); -} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_prompt_suggestion.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_prompt_suggestion.snap deleted file mode 100644 index 3ac49f7032..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_prompt_suggestion.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs -assertion_line: 136 -expression: text(&during_search.0) ---- - -› Ask Codex - - Find: needle - Enter next · Esc close diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs index b546800739..bb14fbe533 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs @@ -67,7 +67,7 @@ pub(super) struct Sparkle { command_input: RefCell, phase: Cell, fresh: bool, - pub(super) terminal_focused: bool, + terminal_focused: bool, } impl Default for Sparkle { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fcd3077742..912880eed1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1306,30 +1306,6 @@ impl BottomPane { // esc_backtrack_hint_visible removed; hints are controlled internally. - pub(crate) fn set_prompt_suggestion( - &mut self, - request: crate::prompt_suggestions::SuggestionRequest, - ) { - self.composer.set_prompt_suggestion(request); - } - - pub(crate) fn has_prompt_suggestion(&self) -> bool { - self.composer.has_prompt_suggestion() - } - - pub(crate) fn clear_prompt_suggestion(&mut self) { - self.composer.clear_prompt_suggestion(); - } - - pub(crate) fn apply_prompt_suggestion( - &mut self, - request: &crate::prompt_suggestions::SuggestionRequest, - text: Option, - ) { - self.composer.apply_prompt_suggestion(request, text); - self.request_redraw(); - } - pub fn set_task_running(&mut self, running: bool) { let was_running = self.is_task_running; self.is_task_running = running; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__prompt_suggestion_24.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__prompt_suggestion_24.snap deleted file mode 100644 index aa7a1d41e7..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__prompt_suggestion_24.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 5274 -expression: terminal.backend() ---- -" " -"› Add a regression test " -" for the timeout " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bad73981fd..eb9c9cdfa2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -287,7 +287,6 @@ mod input_flow; mod input_restore; mod input_submission; mod interrupts; -mod prompt_suggestions; mod questions; mod startup_submission; use self::interrupts::InterruptManager; @@ -657,8 +656,6 @@ pub(crate) struct ChatWidget { pet_image_support_override: Option, thread_id: Option, thread_name: Option, - // Unknown until the server supplies live settings; resume responses omit summary. - pub(crate) prompt_suggestion_summary: Option, thread_rename_block_message: Option, active_side_conversation: bool, blocks_direct_input: bool, @@ -1774,7 +1771,6 @@ impl ChatWidget { } pub(crate) fn show_external_writer_thread(&mut self) { - self.clear_prompt_suggestion(); self.cancel_image_submission(); self.blocks_direct_input = true; self.external_writer_view = true; diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index dcfb96e8b3..fb4575ab0d 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -223,7 +223,6 @@ impl ChatWidget { pet_image_support_override: None, thread_id: None, thread_name: None, - prompt_suggestion_summary: None, thread_rename_block_message: None, active_side_conversation: false, blocks_direct_input: false, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index b22d4088fa..a6b5cdd9a5 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -23,9 +23,6 @@ impl ChatWidget { input_result: InputResult, had_modal_or_popup: bool, ) { - if !matches!(input_result, InputResult::None) { - self.clear_prompt_suggestion(); - } let follow_transcript = match &input_result { // Opening settings is not a request to leave the current reading anchor. // Inline commands still follow so their output (including usage errors) is visible. diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 6a8dfe92e4..a37e938cdd 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -76,7 +76,6 @@ impl ChatWidget { } pub(super) fn submit_user_message(&mut self, user_message: UserMessage) { - self.clear_prompt_suggestion(); let _accepted = self.submit_user_message_with_history_record( user_message, UserMessageHistoryRecord::UserMessageText, diff --git a/codex-rs/tui/src/chatwidget/prompt_suggestions.rs b/codex-rs/tui/src/chatwidget/prompt_suggestions.rs deleted file mode 100644 index ed1e2c3c12..0000000000 --- a/codex-rs/tui/src/chatwidget/prompt_suggestions.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Suggests after successful live turns with known settings. The composer owns cancellation; -//! request identity prevents late results from replacing a newer suggestion. - -use super::*; -use crate::prompt_suggestions::SuggestionRequest; -use tokio_util::sync::CancellationToken; - -impl ChatWidget { - pub(crate) fn has_prompt_suggestion(&self) -> bool { - self.bottom_pane.has_prompt_suggestion() - } - - pub(crate) fn clear_prompt_suggestion(&mut self) { - self.bottom_pane.clear_prompt_suggestion(); - } - - pub(super) fn suggest_next_prompt(&mut self, turn_id: String) { - if !self.local_settings.tui.prompt_suggestions - || self.is_user_turn_pending_or_running() - || self.external_writer_view - || self.blocks_direct_input - || self.side_conversation_active() - || self.has_queued_follow_up_messages() - { - return; - } - let Some(thread_id) = self.thread_id() else { - return; - }; - let Some(summary) = self.prompt_suggestion_summary else { - return; - }; - let request = SuggestionRequest { - thread_id, - turn_id, - summary, - id: uuid::Uuid::new_v4(), - cancellation: CancellationToken::new(), - generation_finished: CancellationToken::new(), - }; - self.bottom_pane.set_prompt_suggestion(request.clone()); - self.app_event_tx - .send(AppEvent::GeneratePromptSuggestion(request)); - } - - pub(crate) fn apply_prompt_suggestion( - &mut self, - request: &SuggestionRequest, - text: Option, - ) { - if self.thread_id() == Some(request.thread_id) && self.local_settings.tui.prompt_suggestions - { - self.bottom_pane.apply_prompt_suggestion(request, text); - } - } -} - -#[cfg(test)] -#[path = "prompt_suggestions_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/chatwidget/prompt_suggestions_tests.rs b/codex-rs/tui/src/chatwidget/prompt_suggestions_tests.rs deleted file mode 100644 index e1686d308a..0000000000 --- a/codex-rs/tui/src/chatwidget/prompt_suggestions_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Tests follow-up suggestion eligibility and deduplication. - -use super::*; -use codex_app_server_protocol::Turn; -use codex_app_server_protocol::TurnStartedNotification; -use pretty_assertions::assert_eq; - -#[tokio::test] -async fn prompt_suggestion_only_follows_enabled_successful_live_turns() { - for (enabled, status, replay, expected) in [ - (false, TurnStatus::Completed, None, 0), - (true, TurnStatus::Failed, None, 0), - (true, TurnStatus::Interrupted, None, 0), - ( - true, - TurnStatus::Completed, - Some(ReplayKind::ResumeInitialMessages), - 0, - ), - (true, TurnStatus::Completed, None, 1), - ] { - let (mut chat, _, mut events, _) = - super::super::tests::helpers::make_chatwidget_manual_with_sender().await; - let thread_id = ThreadId::new(); - chat.thread_id = Some(thread_id); - chat.local_settings.tui.prompt_suggestions = enabled; - chat.prompt_suggestion_summary = Some(codex_protocol::config_types::ReasoningSummary::None); - let turn = Turn { - id: "turn".into(), - items: vec![], - items_view: Default::default(), - status: TurnStatus::InProgress, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }; - chat.handle_server_notification( - ServerNotification::TurnStarted(TurnStartedNotification { - thread_id: thread_id.to_string(), - turn: turn.clone(), - }), - replay, - ); - let completed = ServerNotification::TurnCompleted(TurnCompletedNotification { - thread_id: thread_id.to_string(), - turn: Turn { status, ..turn }, - }); - chat.handle_server_notification(completed.clone(), replay); - chat.handle_server_notification(completed, replay); - let mut requests = 0; - while let Ok(event) = events.try_recv() { - if matches!(event, AppEvent::GeneratePromptSuggestion(_)) { - requests += 1; - } - } - assert_eq!(requests, expected); - } -} diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index f5fe7c0a50..ba0d2e4061 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -73,7 +73,6 @@ impl ChatWidget { self.on_thread_settings_updated(notification); } ServerNotification::TurnStarted(notification) => { - self.clear_prompt_suggestion(); if from_replay { self.restore_realtime_transcripts_before_turn(¬ification.turn.id); } else { @@ -408,10 +407,6 @@ impl ChatWidget { // User-message dedupe only suppresses the app-server echo of a prompt // this TUI already rendered locally. Once that turn ends, another // client can submit the same text and it still needs its own user cell. - let suggest = replay_kind.is_none() - && notification.turn.status == TurnStatus::Completed - && self.turn_lifecycle.agent_turn_running - && self.turn_lifecycle.last_turn_id.as_deref() == Some(notification.turn.id.as_str()); self.last_rendered_user_message_display = None; let mut question_drafts = None; let was_replaying_turn_completion = self.thread_usage.replaying_turn_completion; @@ -527,9 +522,6 @@ impl ChatWidget { self.finish_realtime_turn(¬ification.turn.id); } self.thread_usage.replaying_turn_completion = was_replaying_turn_completion; - if suggest { - self.suggest_next_prompt(notification.turn.id); - } } fn handle_item_started_notification( diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 7cd1531017..a9c2d1932d 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -444,7 +444,6 @@ impl ChatWidget { } fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { - self.prompt_suggestion_summary = settings.summary; self.invalidate_permission_discovery(); let cwd_changed = self.config.cwd != settings.cwd; self.apply_thread_settings_cwd(settings.cwd.clone()); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6fdada2855..1e04ab8575 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -183,7 +183,6 @@ mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; -mod prompt_suggestions; pub(crate) mod public_widgets; mod render; mod resize_reflow_cap; diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 4a470de9b4..4eb78a510c 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -70,7 +70,6 @@ impl LocalSettings { show_tooltips: config.show_tooltips, show_server_version_notice: config.tui_show_server_version_notice, auto_recap: config.tui_auto_recap, - prompt_suggestions: config.tui_prompt_suggestions, disable_paste_burst: Some(config.disable_paste_burst), vim_mode_default: config.tui_vim_mode_default, question_esc_back: config.tui_question_esc_back, diff --git a/codex-rs/tui/src/prompt_suggestions.rs b/codex-rs/tui/src/prompt_suggestions.rs deleted file mode 100644 index 7676bc089c..0000000000 --- a/codex-rs/tui/src/prompt_suggestions.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! A suggestion belongs to one live completed turn and never becomes draft text implicitly. -//! The composer owns cancellation; dropping it also cancels requests during thread switches. - -use codex_protocol::ThreadId; -use serde::Deserialize; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -// Desktop FALLBACK_PROMPT, fallback-1. Keep the source text unchanged. -pub(crate) const PROMPT: &str = include_str!("../assets/prompt_suggestion.txt"); - -#[derive(Clone, Debug)] -pub(crate) struct SuggestionRequest { - pub(crate) thread_id: ThreadId, - pub(crate) turn_id: String, - pub(crate) summary: codex_protocol::config_types::ReasoningSummary, - pub(crate) id: Uuid, - pub(crate) cancellation: CancellationToken, - pub(crate) generation_finished: CancellationToken, -} - -pub(crate) struct PromptSuggestion { - pub(crate) request: SuggestionRequest, - pub(crate) text: Option, -} - -impl Drop for PromptSuggestion { - fn drop(&mut self) { - self.request.cancellation.cancel(); - } -} - -pub(crate) fn parse_suggestion(response: &str) -> Option { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct Response { - suggestion: serde_json::Value, - } - let response = serde_json::from_str::(response).ok()?; - let text = response.suggestion.as_str()?.trim(); - (!text.is_empty() - && text.chars().count() <= 240 - && !text - .chars() - .any(|ch| ch.is_control() || matches!(ch, '\u{2028}' | '\u{2029}'))) - .then(|| text.to_string()) -} - -#[cfg(test)] -#[path = "prompt_suggestions_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/prompt_suggestions_tests.rs b/codex-rs/tui/src/prompt_suggestions_tests.rs deleted file mode 100644 index d781989cc0..0000000000 --- a/codex-rs/tui/src/prompt_suggestions_tests.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Exercise suggestion parsing and composer input behavior. - -use super::*; -use pretty_assertions::assert_eq; - -#[test] -fn prompt_suggestion_requires_bounded_single_line_json() { - for response in [ - "null", - "{}", - r#"{"suggestion":null}"#, - r#"{"suggestion":3}"#, - r#"{"suggestion":""}"#, - r#"{"suggestion":"a\nb"}"#, - r#"{"suggestion":"ok","extra":true}"#, - ] { - assert_eq!(parse_suggestion(response), None, "{response}"); - } - for length in [240, 241] { - let text = "é".repeat(length); - let response = serde_json::json!({"suggestion":text}).to_string(); - assert_eq!(parse_suggestion(&response), (length == 240).then_some(text)); - } - assert_eq!( - parse_suggestion(r#"{"suggestion":" Add a regression test "}"#), - Some("Add a regression test".into()) - ); -}