Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 18 additions & 28 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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
//!
Expand Down Expand Up @@ -3134,7 +3136,7 @@ impl ChatComposer {
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(_, _, _)
) {
self.draft.textarea.enter_vim_normal_mode();
self.draft.textarea.enter_vim_insert_mode();
}
}

Expand Down Expand Up @@ -3394,6 +3396,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))
}

Expand Down Expand Up @@ -3485,26 +3488,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!(
Expand Down Expand Up @@ -6148,13 +6131,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;
Expand All @@ -6180,7 +6163,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) => {
Expand All @@ -6190,6 +6173,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]
Expand Down Expand Up @@ -6400,7 +6390,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;
Expand Down Expand Up @@ -6431,7 +6421,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 {
Expand All @@ -6441,7 +6431,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;
Expand All @@ -6465,7 +6455,7 @@ mod tests {

assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
Some("Vim: Insert".green())
);
assert!(composer.is_empty());
match result {
Expand Down
58 changes: 57 additions & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› draft "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Normal "
3 changes: 3 additions & 0 deletions codex-rs/tui/src/bottom_pane/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/tui/src/bottom_pane/textarea/vim_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/chatwidget/input_restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,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(
Expand Down
11 changes: 8 additions & 3 deletions codex-rs/tui/src/chatwidget/tests/plan_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion codex-rs/tui/src/chatwidget/tests/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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");
}
}

Expand Down
Loading