mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add TUI composer support for prompt suggestions (#47911)
## What changed Add `tui.prompt_suggestions`, defaulting to `false`, along with suggestion request state, a generation prompt, and strict parsing of single-line suggestions up to 240 characters. Suggestion generation is not connected yet. Keep suggestions outside the draft and render them dimly in an empty, focused composer. `Tab` accepts a suggestion for editing, a separate `Enter` submits, and `Escape` cancels pending suggestions. Respect active key bindings, popups, attachments, paste bursts, and interactive transcript footers; ignore stale results and cancel suggestions when a task starts. ## Testing Add parser tests, composer interaction tests, and snapshots covering acceptance, dismissal, stale results, input precedence, wrapping, and transcript search layout. GitOrigin-RevId: 868001a0fc3e6fd8c11ef1650aabc477fa36e03d
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -235,6 +235,7 @@ fn new_config(model: Option<String>, 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(),
|
||||
|
||||
@@ -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.
|
||||
@@ -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<crate::prompt_suggestions::PromptSuggestion>,
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
) {
|
||||
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<Vec<Line<'static>>> {
|
||||
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;
|
||||
@@ -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");
|
||||
}
|
||||
+10
@@ -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
|
||||
@@ -67,7 +67,7 @@ pub(super) struct Sparkle {
|
||||
command_input: RefCell<String>,
|
||||
phase: Cell<Phase>,
|
||||
fresh: bool,
|
||||
terminal_focused: bool,
|
||||
pub(super) terminal_focused: bool,
|
||||
}
|
||||
|
||||
impl Default for Sparkle {
|
||||
|
||||
+14
@@ -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 "
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl Drop for PromptSuggestion {
|
||||
fn drop(&mut self) {
|
||||
self.request.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_suggestion(response: &str) -> Option<String> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Response {
|
||||
suggestion: serde_json::Value,
|
||||
}
|
||||
let response = serde_json::from_str::<Response>(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;
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user