feat(tui) vim mode

This commit is contained in:
Dylan Hurd
2026-02-15 11:21:31 -08:00
committed by Dylan Hurd
parent 85034b189e
commit 44dfbaef1f
7 changed files with 502 additions and 3 deletions
+14 -2
View File
@@ -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() {
+5
View File
@@ -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()
}
+457
View File
@@ -45,6 +45,9 @@ pub(crate) struct TextArea {
elements: Vec<TextElement>,
next_element_id: u64,
kill_buffer: String,
vim_enabled: bool,
vim_mode: VimMode,
vim_operator: Option<VimOperator>,
}
#[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<VimMotion> {
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<Range<usize>> {
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<usize>) {
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<usize> {
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
+4
View File
@@ -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")]
{
@@ -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() {}
}
+4 -1
View File
@@ -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 <absolute_path>"
@@ -129,6 +131,7 @@ impl SlashCommand {
| SlashCommand::Personality
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::Vim
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
+6
View File
@@ -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`: