Start fresh Vim drafts in Insert mode (#41921)

## What changed

- Start the composer in Vim Insert mode when Vim is enabled and return to
  Insert mode after submissions and slash-command dispatch.
- Record the fresh Insert session as a Vim edit so `.` can repeat text entered
  in a new draft.
- Keep `/` and `?` as Normal-mode composer searches, and let Backspace cancel
  an empty search query and any pending operator without changing the draft.

## Testing

- Add coverage for startup, submitted and queued drafts, inline and goal slash
  commands, repeatable edits, and canceled searches.

GitOrigin-RevId: 1a5ee30c44685d2b2a037e46c3ab4ab611024919
(cherry picked from commit 0344625ccf)
This commit is contained in:
Benjamin Carlsson
2026-08-31 15:56:12 -07:00
committed by Roy Han
parent 2c8cfbf44f
commit 772fc234a3
12 changed files with 137 additions and 39 deletions
+18 -28
View File
@@ -21,6 +21,8 @@
//! popup-specific handler if a popup is visible and otherwise to
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
//! Fresh Vim drafts start in Insert; Normal `/` and `?` search the composer.
//! Backspace on an empty Vim search query cancels search and any pending operator.
//!
//! # Completion and Popup Dismissal
//!
@@ -3124,7 +3126,7 @@ impl ChatComposer {
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(_, _, _)
) {
self.draft.textarea.enter_vim_normal_mode();
self.draft.textarea.enter_vim_insert_mode();
}
}
@@ -3384,6 +3386,7 @@ impl ChatComposer {
slash_input::args_elements(prepared_rest, prepared_rest_offset, &prepared_elements);
let trimmed_rest = prepared_rest.trim();
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
self.draft.textarea.enter_vim_insert_mode();
Some((trimmed_rest.to_string(), args_elements))
}
@@ -3475,26 +3478,6 @@ impl ChatComposer {
{
return self.handle_input_basic(key_event);
}
if self.draft.textarea.is_vim_normal_mode()
&& self.is_empty()
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
)
{
self.footer.mode = reset_mode_after_activity(self.footer.mode);
self.draft.textarea.set_text_clearing_elements("/");
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
self.draft.textarea.enter_vim_insert_mode();
return (InputResult::None, true);
}
if self.draft.textarea.is_vim_normal_mode()
&& self.is_empty()
&& matches!(
@@ -6125,13 +6108,13 @@ mod tests {
assert!(composer.is_empty());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
Some("Vim: Insert".green())
);
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
}
#[test]
fn inline_slash_command_dispatch_resets_vim_mode_to_normal() {
fn inline_slash_command_dispatch_resets_vim_mode_to_insert() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -6157,7 +6140,7 @@ mod tests {
assert!(needs_redraw);
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
Some("Vim: Insert".green())
);
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
@@ -6167,6 +6150,13 @@ mod tests {
}
_ => panic!("expected CommandWithArgs"),
}
composer
.prepare_inline_args_submission(/*record_history*/ false)
.unwrap();
for code in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] {
composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(composer.current_text(), "xx");
}
#[test]
@@ -6377,7 +6367,7 @@ mod tests {
}
#[test]
fn vim_mode_resets_to_normal_after_submission() {
fn vim_mode_resets_to_insert_after_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -6408,7 +6398,7 @@ mod tests {
assert!(composer.draft.textarea.is_vim_enabled());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
Some("Vim: Insert".green())
);
assert!(composer.is_empty());
match result {
@@ -6418,7 +6408,7 @@ mod tests {
}
#[test]
fn vim_mode_resets_to_normal_after_queued_submission() {
fn vim_mode_resets_to_insert_after_queued_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -6442,7 +6432,7 @@ mod tests {
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
Some("Vim: Insert".green())
);
assert!(composer.is_empty());
match result {
@@ -1,5 +1,6 @@
//! Search input goes through real composer routing and rendering without changing the draft.
//! Fresh draft entry and composer-local Vim search remain separate input paths.
use super::super::InputResult;
use super::super::tests::snapshot_composer_state_with_width;
use crate::keymap::KeyChordMatch;
use crate::keymap::KeyChordMatcher;
@@ -11,6 +12,61 @@ use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use tokio::time::Instant;
#[test]
fn fresh_vim_draft_records_typing_after_submission() {
snapshot_composer_state_with_width(
"vim_fresh_draft",
/*width*/ 40,
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_disable_paste_burst(/*disabled*/ true);
for entry in [KeyCode::Char('i'), KeyCode::Esc] {
composer.set_text_content("sent".into(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*enabled*/ true);
composer.handle_key_event(entry.into());
let (result, _) = composer.handle_key_event(KeyCode::Enter.into());
assert!(matches!(result, InputResult::Submitted { .. }));
assert_eq!(composer.draft.textarea.vim_mode_label(), Some("Insert"));
for key in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] {
composer.handle_key_event(key.into());
}
assert_eq!(composer.current_text(), "xx");
}
composer.handle_key_event(KeyCode::Enter.into());
},
);
}
#[test]
fn backspace_cancels_empty_vim_search() {
snapshot_composer_state_with_width(
"vim_search_cancelled",
/*width*/ 40,
/*enhanced_keys_supported*/ false,
|composer| {
for text in ["", "draft"] {
composer.set_text_content(text.into(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*enabled*/ true);
let cursor = composer.cursor();
for prefix in ["/", "?", "d/", "d?", "c/", "c?", "y/", "y?"] {
for ch in prefix.chars() {
composer
.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.draft.textarea.vim_query().is_some());
composer.handle_key_event(KeyCode::Backspace.into());
assert!(!composer.draft.textarea.is_vim_operator_pending());
assert!(composer.draft.textarea.is_vim_normal_mode());
assert_eq!(
(composer.current_text(), composer.cursor()),
(text.into(), cursor)
);
}
}
},
);
}
#[test]
fn vim_search_query_edits_and_paste_preserve_the_draft() {
snapshot_composer_state_with_width(
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Insert "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› draft "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Normal "
+3
View File
@@ -338,6 +338,9 @@ impl TextArea {
self.vim_mode = VimMode::Insert;
self.vim_pending = VimPending::None;
self.cancel_vim_search();
if self.vim_commands.pending_change.is_empty() && !self.vim_commands.replaying {
self.start_vim_edit(VimAction::Insert(VimInsertPosition::Cursor));
}
}
}
@@ -85,10 +85,10 @@ pub(super) enum VimAction {
#[derive(Debug, Default)]
pub(super) struct VimCommandState {
pending_change: Vec<VimEdit>,
pub(super) pending_change: Vec<VimEdit>,
last_change: Vec<VimEdit>,
changed: bool,
replaying: bool,
pub(super) replaying: bool,
}
impl TextArea {
@@ -133,6 +133,10 @@ impl TextArea {
self.vim_pending = VimPending::None;
return true;
}
(KeyCode::Backspace, KeyModifiers::NONE) if input.editor.is_empty() => {
self.vim_pending = VimPending::None;
return true;
}
(KeyCode::Enter, KeyModifiers::NONE) => {}
_ => {
input.editor.input_with_keymap(event, &self.editor_keymap);
+5 -3
View File
@@ -265,9 +265,11 @@ impl ChatWidget {
if let Some(keymap) = runtime_keymap {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_vim_enabled(widget.config.tui_vim_mode_default);
if widget.config.tui_vim_mode_default {
widget.bottom_pane.enable_vim_in_insert_mode();
} else {
widget.bottom_pane.set_vim_enabled(/*enabled*/ false);
}
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
+1 -1
View File
@@ -69,7 +69,7 @@ impl ChatWidget {
startup_offset
.saturating_add(draft.cursor)
.saturating_add(cursor_adjustment)
} else if existing_has_content {
} else if existing_has_content || !startup_has_content {
existing_cursor
} else {
self.restore_composer_state(Self::composer_state_from_user_message(
@@ -665,6 +665,9 @@ impl ChatWidget {
.set_composer_text(String::new(), Vec::new(), Vec::new());
self.bottom_pane.set_composer_pending_pastes(Vec::new());
self.bottom_pane.drain_pending_submission_state();
if self.bottom_pane.composer_is_vim_enabled() {
self.bottom_pane.enable_vim_in_insert_mode();
}
}
fn prepared_inline_user_message(
@@ -1596,7 +1596,7 @@ async fn vim_mode_default_disabled_starts_composer_in_insert_mode() {
}
#[tokio::test]
async fn vim_mode_default_enabled_starts_composer_in_normal_mode() {
async fn vim_mode_default_enabled_starts_composer_in_insert_mode() {
let chat = make_startup_chat_with_cli_overrides(vec![(
"tui.vim_mode_default".to_string(),
TomlValue::Boolean(true),
@@ -1606,8 +1606,13 @@ async fn vim_mode_default_enabled_starts_composer_in_normal_mode() {
assert!(chat.bottom_pane.composer_is_vim_enabled());
assert!(chat.composer_is_empty());
let mut chat = chat;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "");
let empty_startup_draft = chat.bottom_pane.composer_draft_snapshot();
chat.restore_startup_draft(empty_startup_draft);
chat.handle_key_event(KeyCode::Char('x').into());
chat.handle_key_event(KeyCode::Right.into());
chat.handle_key_event(KeyCode::Esc.into());
chat.handle_key_event(KeyCode::Char('.').into());
assert_eq!(chat.bottom_pane.composer_text(), "xx");
}
async fn make_startup_chat_with_cli_overrides(
@@ -823,8 +823,12 @@ async fn goal_control_slash_commands_emit_goal_events() {
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.bottom_pane.set_vim_enabled(/*enabled*/ true);
submit_composer_text(&mut chat, command);
chat.bottom_pane
.set_composer_text(command.into(), Vec::new(), Vec::new());
chat.handle_key_event(KeyCode::Esc.into());
chat.handle_key_event(KeyCode::Enter.into());
match status {
Some(status) => {
@@ -850,6 +854,11 @@ async fn goal_control_slash_commands_emit_goal_events() {
assert_eq!(actual_thread_id, thread_id);
}
}
chat.handle_key_event(KeyCode::Char('x').into());
chat.handle_key_event(KeyCode::Right.into());
chat.handle_key_event(KeyCode::Esc.into());
chat.handle_key_event(KeyCode::Char('.').into());
assert_eq!(chat.bottom_pane.composer_text(), "xx");
}
}