mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
committed by
Roy Han
parent
2c8cfbf44f
commit
772fc234a3
@@ -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(
|
||||
|
||||
+13
@@ -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 "
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› draft "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left | Vim: Normal "
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user