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 <noreply@anthropic.com>
This commit is contained in:
wangfeng
2026-05-04 16:32:13 -07:00
parent 3cff070570
commit dc213928ad
4 changed files with 455 additions and 2 deletions
+15
View File
@@ -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!(
+309 -1
View File
@@ -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();
+113
View File
@@ -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());
+18 -1
View File
@@ -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);
}