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
}
/// Return whether Vim editing is enabled for tests that assert mode transitions.
#[cfg(test)]
/// Return whether Vim editing is enabled.
pub(crate) fn is_vim_enabled(&self) -> bool {
self.draft.textarea.is_vim_enabled()
}
@@ -1362,14 +1361,7 @@ impl ChatComposer {
}
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.draft
.textarea
.vim_mode_label()
.map(|label| match label {
"Normal" => "Vim: Normal".magenta(),
"Insert" => "Vim: Insert".green(),
_ => unreachable!(),
})
self.draft.textarea.vim_mode_indicator_span()
}
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::time::Instant;
use crate::key_hint;
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 super::popup_consts::accept_cancel_hint_line;
use super::popup_consts::standard_popup_hint_line;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
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::textarea::TextArea;
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) {
if self.textarea.is_vim_operator_pending() {
self.textarea.input(key_event);
return;
}
match key_event {
KeyEvent {
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 {
code: KeyCode::Enter,
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.textarea.insert_str("\n");
return;
@@ -126,6 +156,10 @@ impl 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) {
self.handle_key_event_at(key_event, Instant::now());
}
@@ -135,6 +169,10 @@ impl BottomPaneView for CustomPromptView {
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 {
self.completion.is_some()
}
@@ -253,15 +291,37 @@ impl Renderable for CustomPromptView {
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(standard_popup_hint_line()).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
let mut hint_line = if self.textarea.uses_vim_insert_cursor() {
accept_cancel_hint_line(
Some(key_hint::plain(KeyCode::Enter).into()),
"to confirm",
Some(key_hint::plain(KeyCode::Esc).into()),
"to enter normal mode",
)
} 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();
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 {
@@ -1,4 +1,7 @@
use super::*;
use crate::key_hint;
use crate::keymap::KeymapContext;
use crate::keymap::RuntimeKeymap;
use pretty_assertions::assert_eq;
use std::sync::mpsc::Receiver;
@@ -74,6 +77,139 @@ fn delayed_enter_after_typing_submits() {
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>) {
let (submitted, submitted_rx) = std::sync::mpsc::channel();
let view = CustomPromptView::new(
+9 -1
View File
@@ -1380,7 +1380,6 @@ impl BottomPane {
self.composer.is_empty()
}
#[cfg(test)]
pub(crate) fn composer_is_vim_enabled(&self) -> bool {
self.composer.is_vim_enabled()
}
@@ -1455,6 +1454,15 @@ impl BottomPane {
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.
pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) {
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::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Span;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use std::borrow::Cow;
@@ -356,6 +358,7 @@ impl TextArea {
/// `None` means Vim editing is disabled, so callers should omit the mode
/// indicator rather than rendering an insert-mode label for normal
/// non-modal editing.
#[cfg(test)]
pub(crate) fn vim_mode_label(&self) -> Option<&'static str> {
if !self.vim_enabled {
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 {
&self.text
}