diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ed1ae8ddbff2..1018d3e808e4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1370,6 +1370,15 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "keymap": { + "allOf": [ + { + "$ref": "#/definitions/TuiKeymap" + } + ], + "default": "standard", + "description": "Composer input keymap mode.\n\n- `standard` (default): current non-modal editing behavior. - `vim`: vim-style modal editing in the composer input." + }, "notification_method": { "allOf": [ { @@ -1409,6 +1418,13 @@ }, "type": "object" }, + "TuiKeymap": { + "enum": [ + "standard", + "vim" + ], + "type": "string" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6474ca4bc03c..bcfe28d2a0c4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -20,6 +20,7 @@ use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; use crate::config::types::Tui; +use crate::config::types::TuiKeymap; use crate::config::types::UriBasedFileOpener; use crate::config::types::WindowsSandboxModeToml; use crate::config::types::WindowsToml; @@ -281,6 +282,9 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Composer input keymap mode for the TUI. + pub tui_keymap: TuiKeymap, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -2124,6 +2128,7 @@ impl Config { .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_keymap: cfg.tui.as_ref().map(|t| t.keymap).unwrap_or_default(), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2546,6 +2551,44 @@ theme = "dracula" assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); } + #[test] + fn tui_keymap_defaults_to_standard() { + let cfg = r#" +[tui] +"#; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.keymap), + Some(TuiKeymap::Standard), + ); + } + + #[test] + fn tui_keymap_deserializes_vim() { + let cfg = r#" +[tui] +keymap = "vim" +"#; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().map(|t| t.keymap), Some(TuiKeymap::Vim)); + } + + #[test] + fn tui_keymap_invalid_value_errors() { + let cfg = r#" +[tui] +keymap = "bad" +"#; + let err = toml::from_str::(cfg).expect_err("invalid keymap should fail"); + let msg = err.to_string(); + assert!( + msg.contains("keymap"), + "expected error mentioning keymap, got: {msg}" + ); + } + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2566,6 +2609,7 @@ theme = "dracula" alternate_screen: AltScreenMode::Auto, status_line: None, theme: None, + keymap: TuiKeymap::Standard, } ); } @@ -4676,6 +4720,7 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_keymap: TuiKeymap::Standard, otel: OtelConfig::default(), }, o3_profile_config @@ -4799,6 +4844,7 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_keymap: TuiKeymap::Standard, otel: OtelConfig::default(), }; @@ -4920,6 +4966,7 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_keymap: TuiKeymap::Standard, otel: OtelConfig::default(), }; @@ -5027,6 +5074,7 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_keymap: TuiKeymap::Standard, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 678766e8078d..32ccb5a44fda 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -639,6 +639,14 @@ impl fmt::Display for NotificationMethod { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum TuiKeymap { + #[default] + Standard, + Vim, +} + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -688,6 +696,13 @@ pub struct Tui { /// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes. #[serde(default)] pub theme: Option, + + /// Composer input keymap mode. + /// + /// - `standard` (default): current non-modal editing behavior. + /// - `vim`: vim-style modal editing in the composer input. + #[serde(default)] + pub keymap: TuiKeymap, } const fn default_true() -> bool { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8498cd9b4282..0bb45bc4c262 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() { @@ -3678,6 +3690,13 @@ impl ChatComposer { show_shortcuts_hint, show_queue_hint, ); + // Render vim mode indicator only in the default footer path so it does not + // obscure flash/status/override content. + if let Some(label) = self.textarea.vim_mode_label() { + let vim_line = Line::from(format!("-- {label} --")).dim(); + let area = inset_footer_hint_area(hint_rect); + vim_line.render(area, buf); + } } if show_right && let Some(line) = &right_line { @@ -3693,10 +3712,12 @@ impl ChatComposer { .render_ref(remote_images_rect, buf); } if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - "›".bold() - } else { + let prompt = if !self.input_enabled { "›".dim() + } else if self.textarea.vim_mode_label().is_some() && !self.textarea.is_vim_insert() { + "·".bold() + } else { + "›".bold() }; buf.set_span( textarea_rect.x - LIVE_PREFIX_COLS, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9c7b940f0e3e..33c2a023ecf4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -297,6 +297,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 2caa45602f5e..fb876fbcb0b8 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -12,6 +12,7 @@ use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use std::cell::Ref; use std::cell::RefCell; +use std::collections::VecDeque; use std::ops::Range; use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; @@ -36,6 +37,15 @@ pub(crate) struct TextElementSnapshot { pub(crate) text: String, } +#[derive(Debug, Clone)] +struct UndoEntry { + text: String, + cursor_pos: usize, + elements: Vec, +} + +const MAX_UNDO_ENTRIES: usize = 100; + #[derive(Debug)] pub(crate) struct TextArea { text: String, @@ -45,6 +55,11 @@ pub(crate) struct TextArea { elements: Vec, next_element_id: u64, kill_buffer: String, + kill_buffer_linewise: bool, + vim_enabled: bool, + vim_mode: VimMode, + vim_operator: Option, + undo_stack: VecDeque, } #[derive(Debug, Clone)] @@ -59,6 +74,21 @@ pub(crate) struct TextAreaState { scroll: u16, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMode { + Normal, + Insert, + /// Waiting for a single replacement character (after pressing `r`). + ReplaceChar, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimOperator { + Delete, + Yank, + Change, +} + impl TextArea { pub fn new() -> Self { Self { @@ -69,6 +99,11 @@ impl TextArea { elements: Vec::new(), next_element_id: 1, kill_buffer: String::new(), + kill_buffer_linewise: false, + vim_enabled: false, + vim_mode: VimMode::Insert, + vim_operator: None, + undo_stack: VecDeque::new(), } } @@ -110,6 +145,60 @@ impl TextArea { self.wrap_cache.replace(None); self.preferred_col = None; self.kill_buffer.clear(); + self.kill_buffer_linewise = false; + } + + 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 + } + + /// Save the current state to the undo stack. + fn push_undo(&mut self) { + if self.undo_stack.len() >= MAX_UNDO_ENTRIES { + self.undo_stack.pop_front(); + } + self.undo_stack.push_back(UndoEntry { + text: self.text.clone(), + cursor_pos: self.cursor_pos, + elements: self.elements.clone(), + }); + } + + /// Restore the most recently saved state from the undo stack. + fn undo(&mut self) { + let Some(entry) = self.undo_stack.pop_back() else { + return; + }; + self.text = entry.text; + self.cursor_pos = entry.cursor_pos; + self.elements = entry.elements; + self.wrap_cache.replace(None); + self.preferred_col = None; + } + + /// Return a label describing the current vim mode, if vim is enabled. + pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { + if !self.vim_enabled { + return None; + } + match self.vim_mode { + VimMode::Normal | VimMode::ReplaceChar => Some("NORMAL"), + VimMode::Insert => Some("INSERT"), + } } pub fn text(&self) -> &str { @@ -211,6 +300,15 @@ impl TextArea { self.text[bol..self.cursor_pos].width() } + fn first_non_blank_of_current_line(&self) -> usize { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + self.text[bol..eol] + .find(|c: char| !c.is_whitespace()) + .map(|offset| bol + offset) + .unwrap_or(eol) + } + fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { // partition_point returns the index of the first element for which // the predicate is false, i.e. the count of elements with start <= pos. @@ -256,6 +354,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 +599,396 @@ 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), + VimMode::ReplaceChar => self.handle_vim_replace_char(event), + } + } + + fn handle_vim_replace_char(&mut self, event: KeyEvent) { + if matches!(event.code, KeyCode::Esc) { + self.vim_mode = VimMode::Normal; + return; + } + if let KeyCode::Char(c) = event.code { + if self.cursor_pos < self.text.len() { + self.push_undo(); + let original_cursor = self.cursor_pos; + let next = self.next_atomic_boundary(self.cursor_pos); + self.replace_range(self.cursor_pos..next, &c.to_string()); + // Keep cursor on the replaced character. + self.set_cursor(original_cursor); + } + } + self.vim_mode = VimMode::Normal; + } + + fn handle_vim_insert(&mut self, event: KeyEvent) { + if matches!(event.code, KeyCode::Esc) { + if self.cursor_pos > 0 { + self.move_cursor_left(); + } + 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() { + self.handle_vim_operator(op, event); + return; + } + + match event { + KeyEvent { + code: KeyCode::Char('i'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.push_undo(); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.push_undo(); + 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.push_undo(); + 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.push_undo(); + self.set_cursor(self.beginning_of_current_line()); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.push_undo(); + let has_next_line = self.text[self.cursor_pos..].contains('\n'); + 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"); + let cursor = if has_next_line { + insert_at + } else { + self.text.len() + }; + self.set_cursor(cursor); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('O'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.push_undo(); + 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.first_non_blank_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.push_undo(); + self.delete_forward_kill(1); + } + KeyEvent { + code: KeyCode::Char('D'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.push_undo(); + 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.push_undo(); + self.paste_after_cursor(); + } + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Delete); + } + KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Change); + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Yank); + } + KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_mode = VimMode::ReplaceChar; + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.undo(); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.vim_operator = None; + } + _ => {} + } + } + + fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) { + // Push undo before destructive operator applications. + if matches!(op, VimOperator::Delete | VimOperator::Change) { + self.push_undo(); + } + match event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Delete => { + self.delete_current_line(); + } + KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Change => { + // cc: clear line content (keep the line itself) and enter insert mode. + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + if bol < eol { + self.kill_range(bol..eol); + } + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Yank => { + self.yank_current_line(); + } + _ => { + if let Some(motion) = self.vim_motion_for_event(event) { + self.apply_vim_operator(op, motion); + } + } + } + } + + 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::FirstNonBlank) + } + 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), + VimOperator::Change => { + self.kill_range(range); + self.vim_mode = VimMode::Insert; + } + } + } + + 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::FirstNonBlank => self.set_cursor(self.first_non_blank_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 +1018,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); @@ -589,9 +1099,76 @@ impl TextArea { } self.kill_buffer = removed; + self.kill_buffer_linewise = false; 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; + self.kill_buffer_linewise = false; + } + + fn paste_after_cursor(&mut self) { + let text = if self.kill_buffer.is_empty() { + // Fall back to system clipboard when the kill buffer is empty. + arboard::Clipboard::new() + .and_then(|mut cb| cb.get_text()) + .ok() + .filter(|s| !s.is_empty()) + } else { + Some(self.kill_buffer.clone()) + }; + let Some(text) = text else { + return; + }; + if self.kill_buffer_linewise { + let eol = self.end_of_current_line(); + let insert_at = if eol < self.text.len() { + eol + 1 + } else { + self.text.len() + }; + self.set_cursor(insert_at); + self.insert_str(&text); + self.set_cursor(insert_at); + } else { + let insert_at = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(insert_at); + self.insert_str(&text); + } + } + + fn yank_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.yank_range(range); + if !self.kill_buffer.is_empty() { + self.kill_buffer_linewise = true; + } + } + + fn delete_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.kill_range(range); + if !self.kill_buffer.is_empty() { + self.kill_buffer_linewise = true; + } + } + + 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 +1722,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 +1810,20 @@ impl TextArea { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + FirstNonBlank, + LineEnd, +} + impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); @@ -1307,6 +1917,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 +2075,148 @@ 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_eq!(t.cursor(), 0); + 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 vim_operator_invalid_followup_is_consumed() { + let mut t = ta_with("hello"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 0); + assert!(!t.is_vim_insert()); + } + + #[test] + fn vim_o_opens_blank_line_below_current_line() { + let mut t = ta_with("one\ntwo"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\nx\ntwo"); + } + + #[test] + fn vim_o_on_last_line_places_cursor_after_inserted_newline() { + let mut t = ta_with("one"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + assert_eq!(t.text(), "one\n"); + assert_eq!(t.cursor(), t.text().len()); + assert!(t.is_vim_insert()); + } + + #[test] + fn vim_yy_and_paste_after_cursor() { + let mut t = ta_with("one\ntwo"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\none\ntwo"); + } + + #[test] + fn vim_dd_deletes_current_line() { + let mut t = ta_with("one\ntwo"); + t.set_cursor(0); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "two"); + } + + #[test] + fn vim_replace_char_keeps_cursor_on_replaced_char() { + let mut t = ta_with("abc"); + t.set_cursor(1); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "aZc"); + assert_eq!(t.cursor(), 1); + assert!(!t.is_vim_insert()); + } + + #[test] + fn vim_u_undoes_replace() { + let mut t = ta_with("abc"); + t.set_cursor(1); + t.set_vim_enabled(true); + + t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "abc"); + } + + #[test] + fn vim_u_undoes_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)); + t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + } + + #[test] + fn vim_undo_stack_is_capped() { + let mut t = ta_with(&"x".repeat(MAX_UNDO_ENTRIES + 5)); + t.set_cursor(0); + t.set_vim_enabled(true); + // Generate many undo snapshots via destructive normal-mode operations. + for _ in 0..(MAX_UNDO_ENTRIES + 5) { + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + } + assert_eq!(t.undo_stack.len(), MAX_UNDO_ENTRIES); + } + #[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 400713b22695..3ff45ae03d76 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2747,6 +2747,7 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget.sync_vim_keymap_enabled(); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -2917,6 +2918,7 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget.sync_vim_keymap_enabled(); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3076,6 +3078,7 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget.sync_vim_keymap_enabled(); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3380,6 +3383,9 @@ impl ChatWidget { SlashCommand::Permissions => { self.open_permissions_popup(); } + SlashCommand::Keymap => { + self.add_error_message("Usage: /keymap standard|vim".to_string()); + } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { @@ -3655,6 +3661,22 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::Keymap if !trimmed.is_empty() => { + match trimmed.to_ascii_lowercase().as_str() { + "vim" => { + self.bottom_pane.set_vim_enabled(true); + self.add_info_message("Composer keymap set to vim.".to_string(), None); + } + "standard" => { + self.bottom_pane.set_vim_enabled(false); + self.add_info_message("Composer keymap set to standard.".to_string(), None); + } + _ => { + self.add_error_message("Usage: /keymap standard|vim".to_string()); + } + } + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } @@ -6391,6 +6413,13 @@ impl ChatWidget { .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } + fn sync_vim_keymap_enabled(&mut self) { + self.bottom_pane.set_vim_enabled(matches!( + self.config.tui_keymap, + codex_core::config::types::TuiKeymap::Vim + )); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.models_manager diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f1f0adf2ac16..3c53907e7c98 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3872,6 +3872,58 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn slash_keymap_switches_between_vim_and_standard() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane + .set_composer_text("/keymap vim".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let vim_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + vim_messages.contains("Composer keymap set to vim."), + "expected vim keymap confirmation, got: {vim_messages:?}" + ); + + chat.bottom_pane + .set_composer_text("/keymap standard".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let standard_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + standard_messages.contains("Composer keymap set to standard."), + "expected standard keymap confirmation, got: {standard_messages:?}" + ); +} + +#[tokio::test] +async fn slash_keymap_invalid_arg_shows_usage_and_keeps_standard_behavior() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane + .set_composer_text("/keymap nope".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let usage_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + usage_messages.contains("Usage: /keymap standard|vim"), + "expected usage message, got: {usage_messages:?}" + ); +} + #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a512060e2b02..ff91c287beee 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,6 +15,7 @@ pub enum SlashCommand { Model, Approvals, Permissions, + Keymap, #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, #[strum(serialize = "sandbox-add-read-dir")] @@ -90,6 +91,7 @@ impl SlashCommand { SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Keymap => "switch composer keymap: /keymap standard|vim", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::SandboxReadRoot => { "let sandbox read a directory: /sandbox-add-read-dir " @@ -117,6 +119,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::SandboxReadRoot + | SlashCommand::Keymap ) } @@ -157,6 +160,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Keymap => true, SlashCommand::Collab => true, SlashCommand::Agent => true, SlashCommand::Statusline => false, @@ -180,3 +184,27 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { .map(|c| (c.command(), c)) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn keymap_command_metadata_is_wired() { + assert_eq!(SlashCommand::Keymap.command(), "keymap"); + assert!(SlashCommand::Keymap.supports_inline_args()); + assert!(SlashCommand::Keymap.available_during_task()); + assert!( + SlashCommand::Keymap + .description() + .contains("/keymap standard|vim") + ); + } + + #[test] + fn built_in_slash_commands_include_keymap() { + let commands = built_in_slash_commands(); + assert!(commands.contains(&("keymap", SlashCommand::Keymap))); + } +} diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 40e23df3ea88..9849c4fb423a 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -38,6 +38,17 @@ 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 `/keymap vim` enables vim mode in the TUI composer; `/keymap standard` restores + the default keymap. The `/keymap` toggle is session-scoped: it does **not** persist across + restarts. To enable vim mode permanently, set `tui.keymap = "vim"` in your config file. +- Scope note: this keymap support is for the in-app composer input only; it does not change key + behavior in external editors launched for message editing. +- `/keymap` can be toggled while a task is running. + ### Key event routing `ChatComposer::handle_key_event` dispatches based on `active_popup`: