mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
bc3545b805
commit
4bb7804a23
10 files changed
+245
-28
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user