From dc213928ade60c701f4ff1a63a49feed5251daa0 Mon Sep 17 00:00:00 2001 From: wangfeng Date: Mon, 4 May 2026 16:32:13 -0700 Subject: [PATCH] feat(tui): vim modal editing in composer (closes #438) Add VimMode {Normal, Insert, Visual} state to the composer. ESC enters Normal; i/a/o enter Insert. Normal-mode motions: h/j/k/l, w/b, 0/$, x (delete char), dd (delete line). Mode indicator shows -- INSERT -- / -- NORMAL -- in the composer footer. Enable with: [composer] mode = "vim" in config (default: normal). Co-Authored-By: Claude Sonnet 4.6 --- crates/tui/src/settings.rs | 15 ++ crates/tui/src/tui/app.rs | 310 +++++++++++++++++++++++++++++- crates/tui/src/tui/ui.rs | 113 +++++++++++ crates/tui/src/tui/widgets/mod.rs | 19 +- 4 files changed, 455 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index c84b0f04..3ec84cff 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -39,6 +39,10 @@ pub struct Settings { pub composer_density: String, /// Show a border around the composer input area pub composer_border: bool, + /// Composer editing mode: "normal" (default) or "vim" for modal editing. + /// When set to "vim" the composer starts in Normal mode; press i/a/o to + /// enter Insert mode and Esc to return to Normal. + pub composer_vim_mode: String, /// Transcript spacing rhythm: compact, comfortable, spacious pub transcript_spacing: String, /// Default mode: "agent", "plan", "yolo" @@ -70,6 +74,7 @@ impl Default for Settings { locale: "auto".to_string(), composer_density: "comfortable".to_string(), composer_border: true, + composer_vim_mode: "normal".to_string(), transcript_spacing: "comfortable".to_string(), default_mode: "agent".to_string(), sidebar_width_percent: 28, @@ -203,6 +208,15 @@ impl Settings { "composer_border" | "border" => { self.composer_border = parse_bool(value)?; } + "composer_vim_mode" | "vim_mode" | "vim" => { + let normalized = value.trim().to_ascii_lowercase(); + if !["vim", "normal"].contains(&normalized.as_str()) { + anyhow::bail!( + "Failed to update setting: invalid composer vim mode '{value}'. Expected: normal, vim." + ); + } + self.composer_vim_mode = normalized; + } "transcript_spacing" | "spacing" => { let normalized = normalize_transcript_spacing(value); if !["compact", "comfortable", "spacious"].contains(&normalized) { @@ -305,6 +319,7 @@ impl Settings { lines.push(format!(" locale: {}", self.locale)); lines.push(format!(" composer_density: {}", self.composer_density)); lines.push(format!(" composer_border: {}", self.composer_border)); + lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode)); lines.push(format!(" transcript_spacing: {}", self.transcript_spacing)); lines.push(format!(" default_mode: {}", self.default_mode)); lines.push(format!( diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f1d274b8..e8f6e9ab 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -430,8 +430,38 @@ struct YoloRestoreState { // === Sub-state structs for App field organization (#377) === +/// Vim modal editing mode for the composer input area. +/// +/// Enabled via `[composer] mode = "vim"` in `settings.toml`. When the +/// composer vim mode is active the user starts in `Normal` mode and presses +/// `i`, `a`, or `o` to enter `Insert` mode. `Esc` from `Insert` returns to +/// `Normal`. Standard vim motions (`h`/`j`/`k`/`l`, `w`/`b`, `0`/`$`, `x`, +/// `dd`) work in `Normal` mode. `Visual` is reserved for future selection +/// support and currently behaves like `Normal`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VimMode { + /// Normal / command mode — motions and operators, no text insertion. + #[default] + Normal, + /// Insert mode — characters are appended at the cursor as typed. + Insert, + /// Visual mode — reserved for future selection support. + Visual, +} + +impl VimMode { + /// Short status-bar label shown in the composer border. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Normal => "-- NORMAL --", + Self::Insert => "-- INSERT --", + Self::Visual => "-- VISUAL --", + } + } +} + /// Composer input state — grouped fields for the text input area. -#[derive(Default)] pub struct ComposerState { /// Current composer text content. pub input: String, @@ -450,6 +480,39 @@ pub struct ComposerState { pub slash_menu_hidden: bool, pub mention_menu_selected: usize, pub mention_menu_hidden: bool, + /// Whether vim modal editing is enabled for this composer. + /// Sourced from `Settings::composer_vim_mode` at startup. + pub vim_enabled: bool, + /// Current vim editing mode. Only meaningful when `vim_enabled` is true. + pub vim_mode: VimMode, + /// Pending `d` prefix for the `dd` delete-line operator. Set when the + /// user presses `d` in Normal mode; cleared on the next key (either `d` + /// to complete `dd`, or any other key to cancel). + pub vim_pending_d: bool, +} + +impl Default for ComposerState { + fn default() -> Self { + Self { + input: String::new(), + cursor_position: 0, + kill_buffer: String::new(), + paste_burst: PasteBurst::default(), + input_history: Vec::new(), + draft_history: VecDeque::new(), + history_index: None, + history_navigation_draft: None, + composer_history_search: None, + selected_attachment_index: None, + slash_menu_selected: 0, + slash_menu_hidden: false, + mention_menu_selected: 0, + mention_menu_hidden: false, + vim_enabled: false, + vim_mode: VimMode::Normal, + vim_pending_d: false, + } + } } /// Viewport/scroll state — fields related to transcript scrolling and caching. @@ -1002,6 +1065,8 @@ impl App { let ui_locale = resolve_locale(&settings.locale); let composer_density = ComposerDensity::from_setting(&settings.composer_density); let composer_border = settings.composer_border; + let composer_vim_enabled = + settings.composer_vim_mode.trim().to_ascii_lowercase() == "vim"; let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing); let sidebar_width_percent = settings.sidebar_width_percent; let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus); @@ -1080,6 +1145,9 @@ impl App { slash_menu_hidden: false, mention_menu_selected: 0, mention_menu_hidden: false, + vim_enabled: composer_vim_enabled, + vim_mode: VimMode::Normal, + vim_pending_d: false, }, viewport: ViewportState::default(), goal: GoalState::default(), @@ -2706,6 +2774,246 @@ impl App { self.needs_redraw = true; } + // === Vim composer mode helpers === + + /// Move the cursor to the start of the current logical line (vim `0`). + pub fn vim_move_line_start(&mut self) { + let text = self.input.clone(); + let cursor_byte = byte_index_at_char(&text, self.cursor_position); + // Walk backward until we find a newline or the start of the string. + let line_start_byte = text[..cursor_byte] + .rfind('\n') + .map_or(0, |idx| idx + 1); + self.cursor_position = char_count(&text[..line_start_byte]); + self.needs_redraw = true; + } + + /// Move the cursor to the end of the current logical line (vim `$`). + pub fn vim_move_line_end(&mut self) { + let text = self.input.clone(); + let cursor_byte = byte_index_at_char(&text, self.cursor_position); + // Walk forward to the next newline or end-of-string. + let line_end_char = text[cursor_byte..] + .find('\n') + .map_or_else(|| char_count(&text), |rel| { + char_count(&text[..cursor_byte + rel]) + }); + self.cursor_position = line_end_char; + self.needs_redraw = true; + } + + /// Move forward one word (vim `w`). Skips over the current word then any + /// trailing whitespace to land on the first character of the next word. + pub fn vim_move_word_forward(&mut self) { + let text = self.input.clone(); + let total = char_count(&text); + let mut pos = self.cursor_position; + if pos >= total { + return; + } + // Skip non-whitespace (current word). + while pos < total { + let byte = byte_index_at_char(&text, pos); + let ch = text[byte..].chars().next().unwrap_or(' '); + if ch.is_whitespace() { + break; + } + pos += 1; + } + // Skip whitespace. + while pos < total { + let byte = byte_index_at_char(&text, pos); + let ch = text[byte..].chars().next().unwrap_or(' '); + if !ch.is_whitespace() { + break; + } + pos += 1; + } + self.cursor_position = pos; + self.needs_redraw = true; + } + + /// Move backward one word (vim `b`). Skips leading whitespace then the + /// preceding word to land on its first character. + pub fn vim_move_word_backward(&mut self) { + let text = self.input.clone(); + let mut pos = self.cursor_position; + if pos == 0 { + return; + } + // Step back one so we're not already at the word start. + pos -= 1; + // Skip whitespace. + while pos > 0 { + let byte = byte_index_at_char(&text, pos); + let ch = text[byte..].chars().next().unwrap_or(' '); + if !ch.is_whitespace() { + break; + } + pos -= 1; + } + // Skip non-whitespace. + while pos > 0 { + let byte = byte_index_at_char(&text, pos - 1); + let ch = text[byte..].chars().next().unwrap_or(' '); + if ch.is_whitespace() { + break; + } + pos -= 1; + } + self.cursor_position = pos; + self.needs_redraw = true; + } + + /// Delete the character under the cursor (vim `x`). + pub fn vim_delete_char_under_cursor(&mut self) { + let total = char_count(&self.input); + if self.cursor_position >= total { + return; + } + let pos = self.cursor_position; + remove_char_at(&mut self.input, pos); + // Keep cursor in bounds after deletion. + let new_total = char_count(&self.input); + if self.cursor_position > 0 && self.cursor_position >= new_total { + self.cursor_position = new_total.saturating_sub(1); + } + self.needs_redraw = true; + } + + /// Delete the entire current logical line (vim `dd`). + pub fn vim_delete_line(&mut self) { + let text = self.input.clone(); + let cursor_byte = byte_index_at_char(&text, self.cursor_position); + let line_start_byte = text[..cursor_byte] + .rfind('\n') + .map_or(0, |idx| idx + 1); + let line_end_byte = text[cursor_byte..] + .find('\n') + .map_or(text.len(), |rel| cursor_byte + rel); + + // Include the trailing newline if present, or the leading newline for the + // very last non-terminated line to avoid leaving a dangling newline. + let (remove_start, remove_end) = if line_end_byte < text.len() { + // There is a newline after the line — remove it too. + (line_start_byte, line_end_byte + 1) + } else if line_start_byte > 0 { + // Last line without trailing newline — remove the preceding newline. + (line_start_byte - 1, line_end_byte) + } else { + // Only line in the buffer. + (line_start_byte, line_end_byte) + }; + + self.input.replace_range(remove_start..remove_end, ""); + self.cursor_position = char_count(&self.input[..remove_start]); + self.needs_redraw = true; + } + + /// Enter insert mode at the cursor (vim `i`). + pub fn vim_enter_insert(&mut self) { + self.vim_mode = VimMode::Insert; + self.needs_redraw = true; + } + + /// Enter insert mode after the cursor (vim `a`). + pub fn vim_enter_append(&mut self) { + let total = char_count(&self.input); + if self.cursor_position < total { + self.cursor_position += 1; + } + self.vim_mode = VimMode::Insert; + self.needs_redraw = true; + } + + /// Open a new line below and enter insert mode (vim `o`). + pub fn vim_open_line_below(&mut self) { + // Move to end of line, then insert a newline. + self.vim_move_line_end(); + self.insert_char('\n'); + self.vim_mode = VimMode::Insert; + } + + /// Return to Normal mode from Insert or Visual (vim `Esc`). + pub fn vim_enter_normal(&mut self) { + self.vim_mode = VimMode::Normal; + self.vim_pending_d = false; + // In Normal mode the cursor sits on a character, not after the last one. + let total = char_count(&self.input); + if self.cursor_position > 0 && self.cursor_position >= total { + self.cursor_position = total.saturating_sub(1); + } + self.needs_redraw = true; + } + + /// Returns `true` when vim mode is active and the composer is in Normal + /// mode, which means character keys should NOT be inserted as text. + #[must_use] + pub fn vim_is_normal_mode(&self) -> bool { + self.composer.vim_enabled && self.composer.vim_mode == VimMode::Normal + } + + /// Returns `true` when vim mode is active and the composer is in Visual mode. + #[must_use] + pub fn vim_is_visual_mode(&self) -> bool { + self.composer.vim_enabled && self.composer.vim_mode == VimMode::Visual + } + + /// Move the cursor down one logical line within the buffer (vim `j`). + /// Falls back to history-down when already on the last line. + pub fn vim_move_down(&mut self) { + let text = self.input.clone(); + let total = char_count(&text); + if self.cursor_position >= total { + self.history_down(); + return; + } + let cursor_byte = byte_index_at_char(&text, self.cursor_position); + let rest = &text[cursor_byte..]; + if let Some(rel_nl) = rest.find('\n') { + // Column offset on the current line. + let line_start_byte = text[..cursor_byte] + .rfind('\n') + .map_or(0, |i| i + 1); + let col = char_count(&text[line_start_byte..cursor_byte]); + let next_line_start = cursor_byte + rel_nl + 1; + let next_line = &text[next_line_start..]; + let next_line_len = next_line.find('\n').unwrap_or(next_line.len()); + let next_line_char_len = char_count(&text[next_line_start + ..next_line_start + next_line_len]); + let target_col = col.min(next_line_char_len); + self.cursor_position = + char_count(&text[..next_line_start]) + target_col; + self.needs_redraw = true; + } else { + self.history_down(); + } + } + + /// Move the cursor up one logical line within the buffer (vim `k`). + /// Falls back to history-up when already on the first line. + pub fn vim_move_up(&mut self) { + let text = self.input.clone(); + let cursor_byte = byte_index_at_char(&text, self.cursor_position); + if let Some(prev_nl) = text[..cursor_byte].rfind('\n') { + // Column on the current line. + let line_start_byte = prev_nl + 1; + let col = char_count(&text[line_start_byte..cursor_byte]); + // Find start of the previous line. + let prev_line_end = prev_nl; // byte of the newline itself + let prev_start = text[..prev_line_end] + .rfind('\n') + .map_or(0, |i| i + 1); + let prev_line_len = char_count(&text[prev_start..prev_line_end]); + let target_col = col.min(prev_line_len); + self.cursor_position = + char_count(&text[..prev_start]) + target_col; + self.needs_redraw = true; + } else { + self.history_up(); + } + } + pub fn clear_input(&mut self) { self.clear_input_history_navigation(); self.input.clear(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 62bc845f..b30db1d3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2094,6 +2094,16 @@ async fn run_event_loop( let _ = engine_handle.send(Op::Shutdown).await; return Ok(()); } + // Vim composer mode: Esc from Insert/Visual → Normal. + // This arm runs before the generic Esc handler so Insert mode + // Esc doesn't accidentally cancel an in-flight request. + KeyCode::Esc + if app.composer.vim_enabled + && app.composer.vim_mode != crate::tui::app::VimMode::Normal => + { + app.vim_enter_normal(); + continue; + } KeyCode::Esc if app.clear_composer_attachment_selection() => { continue; } @@ -2669,6 +2679,28 @@ async fn run_event_loop( open_tool_details_pager(app); continue; } + // Vim composer: Normal-mode motion / operator keys. + // Only fires when vim is enabled, the input is focused (no modal + // open on top), and the key has no modifier (pure char). + KeyCode::Char(c) + if app.vim_is_normal_mode() + && key.modifiers.is_empty() + && !slash_menu_open + && !mention_menu_open + && app.view_stack.is_empty() => + { + handle_vim_normal_key(app, c); + continue; + } + // Vim composer: in Visual mode plain chars are ignored + // (no text insertion until `i` / `a` enters Insert). + KeyCode::Char(_) + if app.vim_is_visual_mode() + && key.modifiers.is_empty() + && app.view_stack.is_empty() => + { + // absorb — Visual mode not yet fully implemented + } KeyCode::Char(c) => { app.insert_char(c); } @@ -2682,6 +2714,87 @@ async fn run_event_loop( } } +/// Handle a plain character key press when the composer is in vim Normal mode. +/// +/// Implements the core set of normal-mode bindings: +/// - `h` / `l` — left / right by character +/// - `j` / `k` — down / up by logical line (falls back to prev/next history) +/// - `w` / `b` — word forward / backward +/// - `0` / `$` — line start / end +/// - `x` — delete character under cursor +/// - `d` (×2) — delete current line (`dd`) +/// - `i` — enter Insert before cursor +/// - `a` — enter Insert after cursor +/// - `o` — open new line below and enter Insert +/// - `v` — enter Visual mode +/// - `G` — move to end of buffer +fn handle_vim_normal_key(app: &mut App, c: char) { + use crate::tui::app::VimMode; + + // Handle pending `d` (waiting for second `d` to complete `dd`). + if app.composer.vim_pending_d { + app.composer.vim_pending_d = false; + if c == 'd' { + app.vim_delete_line(); + } + // Any other key cancels the pending operator. + return; + } + + match c { + 'h' => { + app.move_cursor_left(); + } + 'l' => { + app.move_cursor_right(); + } + 'j' => { + app.vim_move_down(); + } + 'k' => { + app.vim_move_up(); + } + 'w' => { + app.vim_move_word_forward(); + } + 'b' => { + app.vim_move_word_backward(); + } + '0' => { + app.vim_move_line_start(); + } + '$' => { + app.vim_move_line_end(); + } + 'x' => { + app.vim_delete_char_under_cursor(); + } + 'd' => { + // Start the `dd` operator sequence. + app.composer.vim_pending_d = true; + } + 'i' => { + app.vim_enter_insert(); + } + 'a' => { + app.vim_enter_append(); + } + 'o' => { + app.vim_open_line_below(); + } + 'v' => { + app.composer.vim_mode = VimMode::Visual; + app.needs_redraw = true; + } + 'G' => { + app.move_cursor_end(); + } + _ => { + // Unknown normal-mode key — silently ignored in Normal mode. + } + } +} + fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) { app.set_sidebar_focus(SidebarFocus::Agents); app.status_message = Some("Sidebar focus: agents".to_string()); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 35c738c7..81479fc7 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -23,7 +23,7 @@ pub use renderable::Renderable; use std::time::Duration; use crate::palette; -use crate::tui::app::{App, AppMode, ComposerDensity}; +use crate::tui::app::{App, AppMode, ComposerDensity, VimMode}; use crate::tui::approval::{ ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory, }; @@ -548,6 +548,23 @@ impl Renderable for ComposerWidget<'_> { .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style(background); + // Vim mode indicator — shown in the top-right corner of the + // composer border when vim editing is active. + if self.app.composer.vim_enabled { + let color = match self.app.composer.vim_mode { + VimMode::Normal => palette::TEXT_MUTED, + VimMode::Insert => palette::DEEPSEEK_SKY, + VimMode::Visual => palette::MODE_PLAN, + }; + let label = self.app.composer.vim_mode.label(); + block = block.title_top( + Line::from(Span::styled( + label, + Style::default().fg(color).bold(), + )) + .right_aligned(), + ); + } if let Some(hint_line) = hint_line { block = block.title_bottom(hint_line); }