Open slash commands from empty drafts in Vim Normal mode (#50788)

## Why

Pressing `/` in Vim Normal mode starts a composer search, even when the draft is empty. Allow this key to open slash commands directly on an empty draft.

## What changed

When slash commands are enabled, a standalone `/` on an empty draft switches to Insert mode, inserts `/`, and opens command completion. Preserve Vim search behavior for nonempty drafts, `?`, and pending operators.

## Testing

Add a snapshot test for the Insert mode transition and command picker. Exercise Vim slash-command entry in the transcript overlay test across terminal sizes, and extend command sparkle coverage to Vim mode.

GitOrigin-RevId: 806c2f6b841190ccdc1a258f88dfa590773ba33a
This commit is contained in:
Benjamin Carlsson
2026-10-04 04:33:42 +00:00
committed by copyberry
parent acf9818fae
commit b8dceb0d4f
5 changed files with 70 additions and 2 deletions
+22 -1
View File
@@ -1185,10 +1185,30 @@ async fn slash_picker_overlays_history_without_moving_the_transcript_or_composer
))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
for (width, height) in [(80, 14), (32, 14), (80, 7), (80, 5)] {
let size = Size::new(width, height);
tui.terminal.resize(size)?;
app.chat_widget.apply_external_edit("/m".to_string());
app.chat_widget.apply_external_edit(String::new());
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.handle_key_event(KeyCode::Esc.into());
let slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
assert!(!app.handle_owned_transcript_event(
&mut tui,
&mut app_server,
&TuiEvent::Key(slash),
)?);
tokio::time::pause();
for key in [slash, KeyCode::Char('m').into()] {
app.chat_widget.handle_key_event(key);
tokio::time::advance(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay())
.await;
app.chat_widget
.handle_paste_burst_tick(tui.frame_requester());
}
tokio::time::resume();
assert_eq!(app.chat_widget.composer_text_with_pending(), "/m");
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.render_owned_transcript(&mut tui, size)?;
@@ -1260,6 +1280,7 @@ async fn slash_picker_overlays_history_without_moving_the_transcript_or_composer
);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
@@ -36,6 +36,7 @@
//! [`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.
//! On an empty draft, a standalone Normal `/` enters Insert and opens command completion.
//! Backspace on an empty Vim search query cancels search and any pending operator.
//!
//! # Completion and Popup Dismissal
@@ -1915,6 +1916,18 @@ impl ChatComposer {
return (InputResult::None, false);
}
if self.history_search.is_none()
&& !self.popups.active()
&& self.config.slash_commands_enabled
&& self.is_empty()
&& self.draft.textarea.wants_vim_search_key(key_event)
&& !self.draft.textarea.is_vim_operator_pending()
&& key_event.code == KeyCode::Char('/')
&& key_event.modifiers.is_empty()
{
self.draft.textarea.enter_vim_insert_mode();
}
self.draft.textarea_state.get_mut().follow_cursor();
let before = self.before_sparkle_key(key_event);
let result = self.handle_key_event_inner(key_event);
@@ -806,8 +806,9 @@ fn fresh_draft_replacement_ends_search_and_dismisses_sparkles() {
fn arbitrary_commands_preserve_a_never_used_fresh_opportunity() {
palette(|| {
let now = Instant::now();
for completion in [false, true] {
for (completion, vim) in [(false, false), (true, false), (false, true), (true, true)] {
let mut pane = pane();
pane.set_vim_enabled(vim);
pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled());
assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty());
for command in ["/status", "/diff", "/pwd"] {
@@ -2,6 +2,7 @@
use super::super::InputResult;
use super::super::tests::snapshot_composer_state_with_width;
use super::super::tests::type_chars_humanlike;
use crate::keymap::KeyChordMatch;
use crate::keymap::KeyChordMatcher;
use crate::keymap::RuntimeKeymap;
@@ -48,6 +49,9 @@ fn backspace_cancels_empty_vim_search() {
composer.set_vim_enabled(/*enabled*/ true);
let cursor = composer.cursor();
for prefix in ["/", "?", "d/", "d?", "c/", "c?", "y/", "y?"] {
if text.is_empty() && prefix == "/" {
continue;
}
for ch in prefix.chars() {
composer
.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
@@ -107,3 +111,18 @@ fn vim_search_query_edits_and_paste_preserve_the_draft() {
},
);
}
#[test]
fn empty_vim_normal_slash_opens_commands() {
snapshot_composer_state_with_width(
"vim_empty_normal_slash",
/*width*/ 60,
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_vim_enabled(/*enabled*/ true);
type_chars_humanlike(composer, &['/']);
assert_eq!(composer.current_text(), "/");
assert_eq!(composer.draft.textarea.vim_mode_label(), Some("Insert"));
},
);
}
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5217
expression: terminal.backend()
---
"› /model choose what model and reasoning effort to "
" use "
" /ide include current selection, open files, and"
" other context from your IDE "
" /permissions choose what Codex is allowed to do "
" "
"› / "
" "
" 100% context left | Vim: Insert "