diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index bc8fff60c1..9d5c4064b3 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -821,6 +821,10 @@ 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 95af497bdc..89ef425034 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4550,6 +4550,11 @@ "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 f829af0e6e..7cbe6904e5 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1258,6 +1258,7 @@ 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, @@ -4398,6 +4399,7 @@ 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 a4126bb5c5..ebf46362dc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -773,6 +773,9 @@ 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, @@ -4458,6 +4461,7 @@ 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 3b0fa8d8de..a5b2353613 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -235,6 +235,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R 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 new file mode 100644 index 0000000000..b300c92ea2 --- /dev/null +++ b/codex-rs/tui/assets/prompt_suggestion.txt @@ -0,0 +1,5 @@ +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/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6cf0bcc4a1..875a8227ed 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -70,6 +70,11 @@ //! - 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. @@ -377,11 +382,13 @@ 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; @@ -613,6 +620,9 @@ 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, @@ -789,6 +799,9 @@ 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, @@ -1023,51 +1036,6 @@ 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() { @@ -1950,6 +1918,12 @@ 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); } @@ -3505,6 +3479,9 @@ 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); @@ -4368,6 +4345,9 @@ impl ChatComposer { } pub fn set_task_running(&mut self, running: bool) { + if running { + self.clear_prompt_suggestion(); + } self.is_task_running = running; } @@ -4990,9 +4970,11 @@ impl ChatComposer { } } } - if !self.draft.input_enabled || textarea_is_empty { + 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 { let text = if self.draft.input_enabled { - self.placeholder_text.as_str().to_string() + self.placeholder_text.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 b79c28b98a..148afb44ef 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,7 +183,11 @@ impl ChatComposer { .try_into() .unwrap_or(u16::MAX); let remote_images_separator = u16::from(remote_images_height > 0); - self.draft.textarea.desired_height(inner_width) + 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), + ) + 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 5db883b7e6..e041a0b36c 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,6 +104,40 @@ 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 new file mode 100644 index 0000000000..13bf1d0936 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/keymap_bindings.rs @@ -0,0 +1,66 @@ +//! 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 new file mode 100644 index 0000000000..d07cfbe084 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions.rs @@ -0,0 +1,115 @@ +//! 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 { + #[cfg_attr( + not(test), + expect(dead_code, reason = "Connected by the next PR in this stack.") + )] + 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; + } + + #[cfg_attr( + not(test), + expect(dead_code, reason = "Connected by the next PR in this stack.") + )] + 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 new file mode 100644 index 0000000000..b32a8d324e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/prompt_suggestions_tests.rs @@ -0,0 +1,151 @@ +//! 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 new file mode 100644 index 0000000000..3ac49f7032 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_prompt_suggestion.snap @@ -0,0 +1,10 @@ +--- +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 bb14fbe533..b546800739 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, - terminal_focused: bool, + pub(super) terminal_focused: bool, } impl Default for Sparkle { 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 new file mode 100644 index 0000000000..aa7a1d41e7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__prompt_suggestion_24.snap @@ -0,0 +1,14 @@ +--- +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/lib.rs b/codex-rs/tui/src/lib.rs index 892810792a..d933f1931c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -183,6 +183,11 @@ mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; +#[expect( + dead_code, + reason = "The next PR in this stack connects suggestion generation." +)] +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 6a3c00fee5..a8cde6c697 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -71,6 +71,7 @@ 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 new file mode 100644 index 0000000000..7676bc089c --- /dev/null +++ b/codex-rs/tui/src/prompt_suggestions.rs @@ -0,0 +1,51 @@ +//! 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 new file mode 100644 index 0000000000..d781989cc0 --- /dev/null +++ b/codex-rs/tui/src/prompt_suggestions_tests.rs @@ -0,0 +1,28 @@ +//! 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()) + ); +}