Apply composer editing preferences to TUI text prompts (#39618)

## What changed

- Initialize custom text prompts with the main composer's editor keybindings and Vim setting.
- Support Vim mode transitions, operator sequences, cursor styles, keymap contexts, and mode-aware footer hints in these prompts.
- Start Vim prompts in insert mode, use the first `Esc` to enter normal mode, and preserve `Esc` cancellation outside Vim.

## Testing

- Cover remapped editor and Vim commands, mode and context transitions, paste handling, cancellation, and footer rendering at different widths.

GitOrigin-RevId: 7a678310639dafb938084197df23d6d2a080a051
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-08-20 06:35:47 +00:00
1 parent bc3545b805
commit 4bb7804a23
10 files changed
+245 -28

No files matched your search

+2 -10
View File
@@ -1344,8 +1344,7 @@ impl ChatComposer {
enabled enabled
} }
/// Return whether Vim editing is enabled for tests that assert mode transitions. /// Return whether Vim editing is enabled.
#[cfg(test)]
pub(crate) fn is_vim_enabled(&self) -> bool { pub(crate) fn is_vim_enabled(&self) -> bool {
self.draft.textarea.is_vim_enabled() self.draft.textarea.is_vim_enabled()
} }
@@ -1362,14 +1361,7 @@ impl ChatComposer {
} }
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> { fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.draft self.draft.textarea.vim_mode_indicator_span()
.textarea
.vim_mode_label()
.map(|label| match label {
"Normal" => "Vim: Normal".magenta(),
"Insert" => "Vim: Insert".green(),
_ => unreachable!(),
})
} }
fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option<Line<'static>> { fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option<Line<'static>> {
@@ -13,14 +13,21 @@ use ratatui::widgets::Widget;
use std::cell::RefCell; use std::cell::RefCell;
use std::time::Instant; use std::time::Instant;
use crate::key_hint;
use crate::key_hint::has_ctrl_or_alt; use crate::key_hint::has_ctrl_or_alt;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use super::popup_consts::accept_cancel_hint_line;
use super::popup_consts::standard_popup_hint_line; use super::popup_consts::standard_popup_hint_line;
use super::CancellationEvent; use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::BottomPaneView;
use super::bottom_pane_view::ViewCompletion; use super::bottom_pane_view::ViewCompletion;
use super::footer::max_left_width_for_right;
use super::footer::render_context_right;
use super::paste_burst::PasteBurst; use super::paste_burst::PasteBurst;
use super::textarea::TextArea; use super::textarea::TextArea;
use super::textarea::TextAreaState; use super::textarea::TextAreaState;
@@ -68,19 +75,42 @@ impl CustomPromptView {
} }
} }
/// Apply the same editor and Vim bindings used by the main composer.
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.textarea.set_keymap_bindings(keymap);
}
/// Enable Vim editing while keeping the prompt ready for immediate input.
pub(crate) fn enable_vim_in_insert_mode(&mut self) {
self.textarea.set_vim_enabled(/*enabled*/ true);
self.textarea.enter_vim_insert_mode();
}
fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) {
if self.textarea.is_vim_operator_pending() {
self.textarea.input(key_event);
return;
}
match key_event { match key_event {
KeyEvent { KeyEvent {
code: KeyCode::Esc, .. code: KeyCode::Esc, ..
} => { } => {
self.on_ctrl_c(); if self.textarea.should_handle_vim_insert_escape(key_event) {
self.textarea.input(key_event);
self.paste_burst.clear_after_explicit_paste();
} else {
self.on_ctrl_c();
}
} }
KeyEvent { KeyEvent {
code: KeyCode::Enter, code: KeyCode::Enter,
modifiers, modifiers,
.. ..
} => { } => {
if self.paste_burst.direct_insert_newline_should_insert(now) { if self.textarea.allows_paste_burst()
&& self.paste_burst.direct_insert_newline_should_insert(now)
{
self.paste_burst.extend_window(now); self.paste_burst.extend_window(now);
self.textarea.insert_str("\n"); self.textarea.insert_str("\n");
return; return;
@@ -126,6 +156,10 @@ impl CustomPromptView {
} }
impl BottomPaneView for CustomPromptView { impl BottomPaneView for CustomPromptView {
fn keymap_contexts(&self) -> KeymapContextSet {
KeymapContextSet::new(self.textarea.keymap_context())
}
fn handle_key_event(&mut self, key_event: KeyEvent) { fn handle_key_event(&mut self, key_event: KeyEvent) {
self.handle_key_event_at(key_event, Instant::now()); self.handle_key_event_at(key_event, Instant::now());
} }
@@ -135,6 +169,10 @@ impl BottomPaneView for CustomPromptView {
CancellationEvent::Handled CancellationEvent::Handled
} }
fn prefer_esc_to_handle_key_event(&self) -> bool {
self.textarea.uses_vim_insert_cursor() || self.textarea.is_vim_operator_pending()
}
fn is_complete(&self) -> bool { fn is_complete(&self) -> bool {
self.completion.is_some() self.completion.is_some()
} }
@@ -253,15 +291,37 @@ impl Renderable for CustomPromptView {
let hint_y = hint_blank_y.saturating_add(1); let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) { if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(standard_popup_hint_line()).render( let mut hint_line = if self.textarea.uses_vim_insert_cursor() {
Rect { accept_cancel_hint_line(
x: area.x, Some(key_hint::plain(KeyCode::Enter).into()),
y: hint_y, "to confirm",
width: area.width, Some(key_hint::plain(KeyCode::Esc).into()),
height: 1, "to enter normal mode",
}, )
buf, } else {
); standard_popup_hint_line()
};
let hint_area = Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
};
let mut vim_mode = self.textarea.vim_mode_indicator_span().map(Line::from);
let available_hint_width = vim_mode
.as_ref()
.and_then(|line| max_left_width_for_right(hint_area, line.width() as u16))
.filter(|width| *width > 0);
if vim_mode.is_some() && available_hint_width.is_none() {
vim_mode = None;
}
if let Some(width) = available_hint_width {
hint_line = truncate_line_with_ellipsis_if_overflow(hint_line, width as usize);
}
Paragraph::new(hint_line).render(hint_area, buf);
if let Some(line) = vim_mode {
render_context_right(hint_area, buf, &line);
}
} }
} }
@@ -284,6 +344,14 @@ impl Renderable for CustomPromptView {
let state = *self.textarea_state.borrow(); let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state) self.textarea.cursor_pos_with_state(textarea_rect, state)
} }
fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle {
if self.textarea.uses_vim_insert_cursor() {
crossterm::cursor::SetCursorStyle::SteadyBar
} else {
crossterm::cursor::SetCursorStyle::DefaultUserShape
}
}
} }
impl CustomPromptView { impl CustomPromptView {
@@ -1,4 +1,7 @@
use super::*; use super::*;
use crate::key_hint;
use crate::keymap::KeymapContext;
use crate::keymap::RuntimeKeymap;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::sync::mpsc::Receiver; use std::sync::mpsc::Receiver;
@@ -74,6 +77,139 @@ fn delayed_enter_after_typing_submits() {
assert!(view.is_complete()); assert!(view.is_complete());
} }
#[test]
fn vim_insert_escape_clears_paste_burst_before_enter() {
let (mut view, submitted_rx) = custom_prompt_view();
let now = Instant::now();
view.enable_vim_in_insert_mode();
for (idx, ch) in "abc".chars().enumerate() {
view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(idx));
}
view.handle_key_event_at(KeyEvent::from(KeyCode::Esc), now + elapsed(/*ms*/ 3));
view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 4));
assert_eq!(submitted_rx.try_recv(), Ok("abc".to_string()));
assert_eq!(view.completion(), Some(ViewCompletion::Accepted));
}
#[test]
fn vim_prompt_hint_tracks_escape_behavior() {
let (mut view, _submitted_rx) = custom_prompt_view();
let rendered_hint = |view: &CustomPromptView, width: u16| {
let area = Rect::new(0, 0, width, 5);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..area.width)
.map(|x| buf[(x, area.height - 1)].symbol())
.collect::<String>()
.trim_end()
.to_string()
};
let vim_color = |view: &CustomPromptView| {
let area = Rect::new(0, 0, 80, 5);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
buf[(67, 4)].style().fg
};
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back");
view.enable_vim_in_insert_mode();
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to enter normal mode Vim: Insert");
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 60), @"Press enter to confirm or esc to enter norm… Vim: Insert");
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 14), @"Press enter to");
assert_eq!(vim_color(&view), Some(ratatui::style::Color::Green));
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
insta::assert_snapshot!(rendered_hint(&view, /*width*/ 80), @"Press enter to confirm or esc to go back Vim: Normal");
assert_eq!(vim_color(&view), Some(ratatui::style::Color::Magenta));
}
#[test]
fn vim_insert_cursor_tracks_mode_and_normal_mode_commands() {
let (mut view, _submitted_rx) = custom_prompt_view();
let area = Rect::new(0, 0, 80, 10);
view.textarea.set_text_clearing_elements("rename");
view.textarea.set_cursor("rename".len());
let mut config = codex_config::types::TuiKeymap::default();
config.editor.move_left = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("f2".to_string()),
));
let mut keymap = RuntimeKeymap::from_config(&config).expect("valid editor remap");
keymap.vim_normal.delete_char = vec![key_hint::plain(KeyCode::F(3))];
view.set_keymap_bindings(&keymap);
view.handle_key_event(KeyEvent::from(KeyCode::F(2)));
assert_eq!(view.textarea.cursor(), "rename".len() - 1);
view.textarea.set_cursor("rename".len());
assert!(matches!(
view.cursor_style(area),
crossterm::cursor::SetCursorStyle::DefaultUserShape
));
view.enable_vim_in_insert_mode();
assert!(view.prefer_esc_to_handle_key_event());
assert!(view.keymap_contexts().contains(KeymapContext::Editor));
assert!(matches!(
view.cursor_style(area),
crossterm::cursor::SetCursorStyle::SteadyBar
));
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(!view.is_complete());
assert!(!view.prefer_esc_to_handle_key_event());
assert!(view.keymap_contexts().contains(KeymapContext::VimNormal));
assert!(matches!(
view.cursor_style(area),
crossterm::cursor::SetCursorStyle::DefaultUserShape
));
for key_code in [KeyCode::Enter, KeyCode::Esc] {
view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
assert!(view.prefer_esc_to_handle_key_event());
assert!(view.keymap_contexts().contains(KeymapContext::VimOperator));
view.handle_key_event(KeyEvent::from(key_code));
assert!(!view.is_complete());
assert!(!view.prefer_esc_to_handle_key_event());
assert_eq!(view.textarea.text(), "rename");
}
view.handle_key_event(KeyEvent::from(KeyCode::Char('d')));
view.handle_key_event(KeyEvent::from(KeyCode::Char('i')));
assert!(
view.keymap_contexts()
.contains(KeymapContext::VimTextObject)
);
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
view.handle_key_event(KeyEvent::from(KeyCode::Char('h')));
view.handle_key_event(KeyEvent::from(KeyCode::F(3)));
assert_eq!(view.textarea.text(), "renae");
view.handle_key_event(KeyEvent::from(KeyCode::Char('i')));
assert!(matches!(
view.cursor_style(area),
crossterm::cursor::SetCursorStyle::SteadyBar
));
view.handle_key_event(KeyEvent::from(KeyCode::Char('m')));
assert_eq!(view.textarea.text(), "rename");
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(view.is_complete());
assert_eq!(view.completion(), Some(ViewCompletion::Cancelled));
}
#[test]
fn escape_without_vim_cancels_prompt() {
let (mut view, _submitted_rx) = custom_prompt_view();
view.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(view.is_complete());
assert_eq!(view.completion(), Some(ViewCompletion::Cancelled));
}
fn custom_prompt_view() -> (CustomPromptView, Receiver<String>) { fn custom_prompt_view() -> (CustomPromptView, Receiver<String>) {
let (submitted, submitted_rx) = std::sync::mpsc::channel(); let (submitted, submitted_rx) = std::sync::mpsc::channel();
let view = CustomPromptView::new( let view = CustomPromptView::new(
+9 -1
View File
@@ -1380,7 +1380,6 @@ impl BottomPane {
self.composer.is_empty() self.composer.is_empty()
} }
#[cfg(test)]
pub(crate) fn composer_is_vim_enabled(&self) -> bool { pub(crate) fn composer_is_vim_enabled(&self) -> bool {
self.composer.is_vim_enabled() self.composer.is_vim_enabled()
} }
@@ -1455,6 +1454,15 @@ impl BottomPane {
self.push_view(view); self.push_view(view);
} }
/// Show a text prompt with the composer's current editing preferences.
pub(crate) fn show_text_prompt(&mut self, mut view: custom_prompt_view::CustomPromptView) {
view.set_keymap_bindings(&self.keymap);
if self.composer_is_vim_enabled() {
view.enable_vim_in_insert_mode();
}
self.push_view(Box::new(view));
}
/// Called when the agent requests user approval. /// Called when the agent requests user approval.
pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) {
let request = if let Some(view) = self.view_stack.last_mut() { let request = if let Some(view) = self.view_stack.last_mut() {
+14
View File
@@ -33,6 +33,8 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Span;
use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use std::borrow::Cow; use std::borrow::Cow;
@@ -356,6 +358,7 @@ impl TextArea {
/// `None` means Vim editing is disabled, so callers should omit the mode /// `None` means Vim editing is disabled, so callers should omit the mode
/// indicator rather than rendering an insert-mode label for normal /// indicator rather than rendering an insert-mode label for normal
/// non-modal editing. /// non-modal editing.
#[cfg(test)]
pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { pub(crate) fn vim_mode_label(&self) -> Option<&'static str> {
if !self.vim_enabled { if !self.vim_enabled {
return None; return None;
@@ -366,6 +369,17 @@ impl TextArea {
}) })
} }
/// Return the styled footer indicator for the active Vim editing mode.
pub(crate) fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
if !self.vim_enabled {
return None;
}
Some(match self.vim_mode {
VimMode::Normal => "Vim: Normal".magenta(),
VimMode::Insert => "Vim: Insert".green(),
})
}
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
&self.text &self.text
} }
+1 -1
View File
@@ -33,7 +33,7 @@ impl ChatWidget {
}); });
}), }),
); );
self.bottom_pane.show_view(Box::new(view)); self.bottom_pane.show_text_prompt(view);
} }
pub(crate) fn show_resume_paused_goal_prompt( pub(crate) fn show_resume_paused_goal_prompt(
+1 -2
View File
@@ -357,8 +357,7 @@ impl ChatWidget {
tx.set_thread_name(name); tx.set_thread_name(name);
}), }),
); );
self.bottom_pane.show_text_prompt(view);
self.bottom_pane.show_view(Box::new(view));
} }
pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool { pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool {
+1 -1
View File
@@ -303,7 +303,7 @@ impl ChatWidget {
}); });
}), }),
); );
self.bottom_pane.show_view(Box::new(view)); self.bottom_pane.show_text_prompt(view);
} }
pub(crate) fn open_marketplace_add_loading_popup(&mut self, _source: &str) { pub(crate) fn open_marketplace_add_loading_popup(&mut self, _source: &str) {
+1 -1
View File
@@ -142,7 +142,7 @@ impl ChatWidget {
}); });
}), }),
); );
self.bottom_pane.show_view(Box::new(view)); self.bottom_pane.show_text_prompt(view);
} }
} }
@@ -68,7 +68,7 @@ impl ChatWidget {
}); });
}), }),
); );
self.bottom_pane.show_view(Box::new(view)); self.bottom_pane.show_text_prompt(view);
self.request_redraw(); self.request_redraw();
} }
} }