diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 36142cbaed..f854a41931 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -109,6 +109,12 @@ //! edits and renders a placeholder prompt instead of the editable textarea. This is part of the //! overall state machine, since it affects which transitions are even possible from a given UI //! state. +//! +//! # Edit Modes +//! +//! The composer supports Emacs-style input (default) and Vim-style modal input. Vim mode uses the +//! textarea's normal/insert states; paste-burst detection is disabled while in Vim normal mode so +//! rapid command keystrokes are not buffered as paste. use crate::bottom_pane::footer::mode_indicator_line; use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; use crate::key_hint; @@ -766,6 +772,12 @@ impl ChatComposer { self.sync_popups(); } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.textarea.set_vim_enabled(enabled); + self.paste_burst.clear_after_explicit_paste(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + pub(crate) fn current_text_with_pending(&self) -> String { let mut text = self.textarea.text().to_string(); for (placeholder, actual) in &self.pending_pastes { @@ -2511,7 +2523,7 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - if self.is_empty() { + if self.is_empty() && !self.textarea.is_vim_insert() { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); if next_mode != self.footer_mode { self.footer_mode = next_mode; @@ -2669,7 +2681,7 @@ impl ChatComposer { } = input { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst { + if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid // holding the first char while still allowing burst detection for paste input. if !ch.is_ascii() { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 43aecb4940..2ea16f79ae 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -287,6 +287,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.composer.set_vim_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2caa45602f..172e2b7330 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -45,6 +45,9 @@ pub(crate) struct TextArea { elements: Vec, next_element_id: u64, kill_buffer: String, + vim_enabled: bool, + vim_mode: VimMode, + vim_operator: Option, } #[derive(Debug, Clone)] @@ -59,6 +62,18 @@ pub(crate) struct TextAreaState { scroll: u16, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMode { + Normal, + Insert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimOperator { + Delete, + Yank, +} + impl TextArea { pub fn new() -> Self { Self { @@ -69,6 +84,9 @@ impl TextArea { elements: Vec::new(), next_element_id: 1, kill_buffer: String::new(), + vim_enabled: false, + vim_mode: VimMode::Insert, + vim_operator: None, } } @@ -112,6 +130,24 @@ impl TextArea { self.kill_buffer.clear(); } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.vim_enabled = enabled; + self.vim_operator = None; + self.vim_mode = if enabled { + VimMode::Normal + } else { + VimMode::Insert + }; + } + + pub(crate) fn allows_paste_burst(&self) -> bool { + !self.vim_enabled || self.vim_mode == VimMode::Insert + } + + pub(crate) fn is_vim_insert(&self) -> bool { + self.vim_enabled && self.vim_mode == VimMode::Insert + } + pub fn text(&self) -> &str { &self.text } @@ -256,6 +292,14 @@ impl TextArea { } pub fn input(&mut self, event: KeyEvent) { + if self.vim_enabled { + self.handle_vim_input(event); + } else { + self.handle_emacs_input(event); + } + } + + fn handle_emacs_input(&mut self, event: KeyEvent) { match event { // Some terminals (or configurations) send Control key chords as // C0 control characters without reporting the CONTROL modifier. @@ -493,6 +537,305 @@ impl TextArea { } } + fn handle_vim_input(&mut self, event: KeyEvent) { + match self.vim_mode { + VimMode::Insert => self.handle_vim_insert(event), + VimMode::Normal => self.handle_vim_normal(event), + } + } + + fn handle_vim_insert(&mut self, event: KeyEvent) { + if matches!(event.code, KeyCode::Esc) { + self.vim_mode = VimMode::Normal; + self.vim_operator = None; + self.preferred_col = None; + return; + } + self.handle_emacs_input(event); + } + + fn handle_vim_normal(&mut self, event: KeyEvent) { + if let Some(op) = self.vim_operator.take() { + if self.handle_vim_operator(op, event) { + return; + } + } + + match event { + KeyEvent { + code: KeyCode::Char('i'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + .. + } => { + let next = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(next); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('A'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.set_cursor(self.end_of_current_line()); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('I'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.set_cursor(self.beginning_of_current_line()); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + .. + } => { + let eol = self.end_of_current_line(); + let insert_at = if eol < self.text.len() { eol + 1 } else { eol }; + self.insert_str_at(insert_at, "\n"); + self.set_cursor(insert_at + 1); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('O'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + let bol = self.beginning_of_current_line(); + self.insert_str_at(bol, "\n"); + self.set_cursor(bol); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_left(), + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_right(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_down(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_up(), + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.beginning_of_next_word()), + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.beginning_of_previous_word()), + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.end_of_next_word()), + KeyEvent { + code: KeyCode::Char('0'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.beginning_of_current_line()), + KeyEvent { + code: KeyCode::Char('$'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.set_cursor(self.end_of_current_line()); + } + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.delete_forward_kill(1); + } + KeyEvent { + code: KeyCode::Char('D'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.kill_to_end_of_line(); + } + KeyEvent { + code: KeyCode::Char('Y'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.yank_current_line(); + } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + .. + } => self.paste_after_cursor(), + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Delete); + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Yank); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.vim_operator = None; + } + _ => {} + } + } + + fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { + match event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Delete => { + self.delete_current_line(); + true + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Yank => { + self.yank_current_line(); + true + } + _ => { + if let Some(motion) = self.vim_motion_for_event(event) { + self.apply_vim_operator(op, motion); + return true; + } + false + } + } + } + + fn vim_motion_for_event(&self, event: KeyEvent) -> Option { + match event { + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Left), + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Right), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Down), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Up), + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::WordForward), + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::WordBackward), + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::WordEnd), + KeyEvent { + code: KeyCode::Char('0'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::LineStart), + KeyEvent { + code: KeyCode::Char('$'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + Some(VimMotion::LineEnd) + } + _ => None, + } + } + + fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) { + let Some(range) = self.range_for_motion(motion) else { + return; + }; + match op { + VimOperator::Delete => self.kill_range(range), + VimOperator::Yank => self.yank_range(range), + } + } + + fn range_for_motion(&mut self, motion: VimMotion) -> Option> { + let start = self.cursor_pos; + let target = self.target_for_motion(motion); + if start == target { + return None; + } + let (range_start, range_end) = if target < start { + (target, start) + } else { + (start, target) + }; + Some(range_start..range_end) + } + + fn target_for_motion(&mut self, motion: VimMotion) -> usize { + let original_cursor = self.cursor_pos; + let original_preferred = self.preferred_col; + match motion { + VimMotion::Left => self.move_cursor_left(), + VimMotion::Right => self.move_cursor_right(), + VimMotion::Up => self.move_cursor_up(), + VimMotion::Down => self.move_cursor_down(), + VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), + VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), + VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), + VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), + } + let target = self.cursor_pos; + self.cursor_pos = original_cursor; + self.preferred_col = original_preferred; + target + } + // ####### Input Functions ####### pub fn delete_backward(&mut self, n: usize) { if n == 0 || self.cursor_pos == 0 { @@ -522,6 +865,20 @@ impl TextArea { self.replace_range(self.cursor_pos..target, ""); } + pub fn delete_forward_kill(&mut self, n: usize) { + if n == 0 || self.cursor_pos >= self.text.len() { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.next_atomic_boundary(target); + if target >= self.text.len() { + break; + } + } + self.kill_range(self.cursor_pos..target); + } + pub fn delete_backward_word(&mut self) { let start = self.beginning_of_previous_word(); self.kill_range(start..self.cursor_pos); @@ -592,6 +949,45 @@ impl TextArea { self.replace_range_raw(range, ""); } + fn yank_range(&mut self, range: Range) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + let removed = self.text[range].to_string(); + if removed.is_empty() { + return; + } + self.kill_buffer = removed; + } + + fn paste_after_cursor(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + let insert_at = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(insert_at); + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn yank_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.yank_range(range); + } + + fn delete_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.kill_range(range); + } + + fn current_line_range_with_newline(&self) -> Range { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + let end = if eol < self.text.len() { eol + 1 } else { eol }; + bol..end + } + /// Move the cursor left by a single grapheme cluster. pub fn move_cursor_left(&mut self) { self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); @@ -1145,6 +1541,25 @@ impl TextArea { self.adjust_pos_out_of_elements(end, false) } + pub(crate) fn beginning_of_next_word(&self) -> usize { + let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) + else { + return self.text.len(); + }; + let word_start = self.cursor_pos + first_non_ws; + if word_start != self.cursor_pos { + return self.adjust_pos_out_of_elements(word_start, true); + } + let end = self.end_of_next_word(); + if end >= self.text.len() { + return self.text.len(); + } + let Some(next_non_ws) = self.text[end..].find(|c: char| !c.is_whitespace()) else { + return self.text.len(); + }; + self.adjust_pos_out_of_elements(end + next_non_ws, true) + } + fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { if let Some(idx) = self.find_element_containing(pos) { let e = &self.elements[idx]; @@ -1214,6 +1629,19 @@ impl TextArea { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, +} + impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); @@ -1307,6 +1735,9 @@ impl TextArea { mod tests { use super::*; // crossterm types are intentionally not imported here to avoid unused warnings + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use rand::prelude::*; @@ -1462,6 +1893,32 @@ mod tests { assert_eq!(t.cursor(), elem_start); } + #[test] + fn vim_insert_and_escape() { + let mut t = TextArea::new(); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.text(), "h"); + assert!(!t.is_vim_insert()); + } + + #[test] + fn vim_delete_word() { + let mut t = ta_with("hello world"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + } + #[test] fn delete_backward_word_and_kill_line_variants() { // delete backward word at end removes the whole previous word diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f72255b22b..8184a53084 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3324,6 +3324,10 @@ impl ChatWidget { SlashCommand::Permissions => { self.open_permissions_popup(); } + SlashCommand::Vim => { + self.bottom_pane.set_vim_enabled(true); + self.add_info_message("Composer input set to vim mode.".to_string(), None); + } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 46a7e72bcf..130bbb7d32 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -14,6 +14,7 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; /// Action returned from feeding a key event into the ComposerInput. pub enum ComposerAction { @@ -56,6 +57,10 @@ impl ComposerInput { pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { InputResult::Submitted { text, .. } => ComposerAction::Submitted(text), + InputResult::Command(cmd) => { + self.handle_slash_command(cmd); + ComposerAction::None + } _ => ComposerAction::None, }; self.drain_app_events(); @@ -117,6 +122,13 @@ impl ComposerInput { crate::bottom_pane::ChatComposer::recommended_paste_flush_delay() } + fn handle_slash_command(&mut self, cmd: SlashCommand) { + match cmd { + SlashCommand::Vim => self.inner.set_vim_enabled(true), + _ => {} + } + } + fn drain_app_events(&mut self) { while self.rx.try_recv().is_ok() {} } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 16e767f802..17ff4b1991 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -16,6 +16,7 @@ pub enum SlashCommand { Approvals, Permissions, #[strum(serialize = "setup-default-sandbox")] + Vim, ElevateSandbox, #[strum(serialize = "sandbox-add-read-dir")] SandboxReadRoot, @@ -84,8 +85,9 @@ impl SlashCommand { SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent => "switch the active agent thread", - SlashCommand::Approvals => "choose what Codex is allowed to do", + SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Vim => "enable vim key bindings for the composer", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::SandboxReadRoot => { "let sandbox read a directory: /sandbox-add-read-dir " @@ -129,6 +131,7 @@ impl SlashCommand { | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions + | SlashCommand::Vim | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot | SlashCommand::Experimental diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 40e23df3ea..6b3676df8e 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -38,6 +38,12 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl 2. **Paste burst**: transient detection state for non-bracketed paste. - implemented by `PasteBurst` +`ChatComposer` also coordinates the textarea input mode: + +- **Vim mode**: modal editing (normal/insert). While in Vim normal mode, paste-burst detection + is disabled so rapid command keystrokes are never buffered as paste. +- Slash command `/vim` enables vim mode in the TUI and the public composer widget. + ### Key event routing `ChatComposer::handle_key_event` dispatches based on `active_popup`: