Skip to content

Commit 049f640

Browse files
ammar-agentammario
andauthored
🤖 Fix: Vim 'e' motion and add '_' motion (#116)
Fixes two Vim mode bugs using TDD: ## Bug 1: `e` only jumps to end of current word When cursor was at the end of a word, pressing `e` would not move to the next word. **Before:** - Text: `hello world` - Cursor on 'o' (end of "hello") - Press `e` → stays at 'o' ❌ **After:** - Cursor moves to 'd' (end of "world") ✅ ## Bug 2: `_` motion not implemented The `_` motion (jump to first non-whitespace character) was missing. **Now supported:** - `_` - Jump to first non-whitespace character - `d_` - Delete to first non-whitespace character - `c_` - Change to first non-whitespace character - `y_` - Yank to first non-whitespace character ## Implementation Details **TDD Approach:** 1. ✅ Wrote 6 failing tests first 2. ✅ Implemented fixes 3. ✅ All 45 tests passing **Changes:** - Fixed `moveWordEnd()` to detect when at end of word - Added `moveToFirstNonWhitespace()` helper - Wired up `_` motion in navigation and operator handlers - Updated documentation ## Testing ``` 45 pass 0 fail 81 expect() calls ``` _Generated with `cmux`_ --------- Co-authored-by: Ammar Bandukwala <ammar@ammar.io>
1 parent 68e622a commit 049f640

File tree

3 files changed

+113
-5
lines changed

3 files changed

+113
-5
lines changed

docs/vim-mode.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Vim mode is always enabled. Press **ESC** to enter normal mode from insert mode.
4848
### Line Movement
4949

5050
- **0** - Move to beginning of line
51+
- **_** - Move to first non-whitespace character of line
5152
- **$** - Move to end of line
5253
- **Home** - Same as **0**
5354
- **End** - Same as **$**
@@ -101,6 +102,7 @@ Vim's power comes from combining operators with motions. All operators work with
101102
- **e** - To end of word
102103
- **$** - To end of line
103104
- **0** - To beginning of line
105+
- **_** - To first non-whitespace character
104106

105107
### Examples
106108

src/utils/vim.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,5 +442,77 @@ describe("Vim Command Integration Tests", () => {
442442
expect(state.text).toBe("h world");
443443
expect(state.mode).toBe("insert");
444444
});
445+
446+
test("e at end of word moves to end of next word", () => {
447+
// Bug: when cursor is at end of word, 'e' should move to end of next word
448+
const state = executeVimCommands(
449+
{ ...initialState, text: "hello world foo", cursor: 4, mode: "normal" }, // cursor on 'o' (end of "hello")
450+
["e"]
451+
);
452+
expect(state.cursor).toBe(10); // Should move to end of "world" (not stay at 4)
453+
});
454+
455+
test("e at end of word with punctuation moves correctly", () => {
456+
const state = executeVimCommands(
457+
{ ...initialState, text: "hello, world", cursor: 4, mode: "normal" }, // cursor on 'o' (end of "hello")
458+
["e"]
459+
);
460+
expect(state.cursor).toBe(11); // Should move to end of "world"
461+
});
462+
});
463+
464+
describe("_ motion (first non-whitespace character)", () => {
465+
test("_ moves to first non-whitespace character", () => {
466+
const state = executeVimCommands(
467+
{ ...initialState, text: " hello world", cursor: 10, mode: "normal" },
468+
["_"]
469+
);
470+
expect(state.cursor).toBe(2); // Should move to 'h' (first non-whitespace)
471+
});
472+
473+
test("_ on line with no leading whitespace goes to position 0", () => {
474+
const state = executeVimCommands(
475+
{ ...initialState, text: "hello world", cursor: 6, mode: "normal" },
476+
["_"]
477+
);
478+
expect(state.cursor).toBe(0); // Should move to start of line
479+
});
480+
481+
test("_ with tabs and spaces", () => {
482+
const state = executeVimCommands(
483+
{ ...initialState, text: "\t hello", cursor: 5, mode: "normal" },
484+
["_"]
485+
);
486+
expect(state.cursor).toBe(3); // Should move to 'h' after tab and spaces
487+
});
488+
489+
test("d_ deletes entire line and newline (linewise motion)", () => {
490+
const state = executeVimCommands(
491+
{ ...initialState, text: " hello world\nnext", cursor: 10, mode: "normal" },
492+
["d", "_"]
493+
);
494+
expect(state.text).toBe("next"); // Entire current line removed (including newline)
495+
expect(state.cursor).toBe(0);
496+
});
497+
498+
test("c_ changes entire line like cc", () => {
499+
const state = executeVimCommands(
500+
{ ...initialState, text: " hello world\nnext", cursor: 5, mode: "normal" },
501+
["c", "_"]
502+
);
503+
expect(state.text).toBe("\nnext"); // Line cleared and enters insert mode
504+
expect(state.mode).toBe("insert");
505+
expect(state.cursor).toBe(0);
506+
});
507+
508+
test("y_ yanks entire line", () => {
509+
const state = executeVimCommands(
510+
{ ...initialState, text: " hello world\nnext", cursor: 3, mode: "normal" },
511+
["y", "_"]
512+
);
513+
expect(state.yankBuffer).toBe(" hello world\n");
514+
expect(state.text).toBe(" hello world\nnext");
515+
});
445516
});
446517
});
518+

src/utils/vim.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ export function getLineBounds(
9090
return { lineStart, lineEnd, row };
9191
}
9292

93+
/**
94+
* Move to first non-whitespace character on current line (like '_').
95+
*/
96+
export function moveToFirstNonWhitespace(text: string, cursor: number): number {
97+
const { lineStart, lineEnd } = getLineBounds(text, cursor);
98+
let i = lineStart;
99+
while (i < lineEnd && /\s/.test(text[i])) {
100+
i++;
101+
}
102+
// If entire line is whitespace, go to line start
103+
return i >= lineEnd ? lineStart : i;
104+
}
105+
106+
93107
/**
94108
* Move cursor vertically by delta lines, maintaining desiredColumn if provided.
95109
*/
@@ -126,6 +140,7 @@ export function moveWordForward(text: string, cursor: number): number {
126140
/**
127141
* Move cursor to end of current/next word (like 'e').
128142
* If on a word character, goes to end of current word.
143+
* If already at end of word, goes to end of next word.
129144
* If on whitespace, goes to end of next word.
130145
*/
131146
export function moveWordEnd(text: string, cursor: number): number {
@@ -135,8 +150,18 @@ export function moveWordEnd(text: string, cursor: number): number {
135150
let i = cursor;
136151
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
137152

138-
// If on a word char, move to end of this word
153+
// If on a word char, check if we're at the end of it
139154
if (isWord(text[i])) {
155+
// If next char is not a word char, we're at the end - move to next word
156+
if (i < n - 1 && !isWord(text[i + 1])) {
157+
// Skip whitespace to find next word
158+
i++;
159+
while (i < n - 1 && !isWord(text[i])) i++;
160+
// Move to end of next word
161+
while (i < n - 1 && isWord(text[i + 1])) i++;
162+
return i;
163+
}
164+
// Not at end yet, move to end of current word
140165
while (i < n - 1 && isWord(text[i + 1])) i++;
141166
return i;
142167
}
@@ -474,6 +499,9 @@ function handlePendingOperator(
474499
if (key === "0" || key === "Home") {
475500
return { handled: true, newState: applyOperatorMotion(state, pending.op, "0") };
476501
}
502+
if (key === "_") {
503+
return { handled: true, newState: applyOperatorMotion(state, pending.op, "_") };
504+
}
477505
// Text object prefix
478506
if (key === "i") {
479507
return handleKey(state, { pendingOp: { op: pending.op, at: now, args: ["i"] } });
@@ -513,7 +541,7 @@ function handleKey(state: VimState, updates: Partial<VimState>): VimKeyResult {
513541
function getMotionRange(
514542
text: string,
515543
cursor: number,
516-
motion: "w" | "b" | "e" | "$" | "0" | "line"
544+
motion: "w" | "b" | "e" | "$" | "0" | "_" | "line"
517545
): { from: number; to: number } | null {
518546
switch (motion) {
519547
case "w":
@@ -530,6 +558,9 @@ function getMotionRange(
530558
const { lineStart } = getLineBounds(text, cursor);
531559
return { from: lineStart, to: cursor };
532560
}
561+
case "_":
562+
// '_' is a linewise motion in Vim - operates on whole lines
563+
return null; // Use linewise handling like 'dd'
533564
case "line":
534565
return null; // Special case: handled separately
535566
}
@@ -541,12 +572,12 @@ function getMotionRange(
541572
function applyOperatorMotion(
542573
state: VimState,
543574
op: "d" | "c" | "y",
544-
motion: "w" | "b" | "e" | "$" | "0" | "line"
575+
motion: "w" | "b" | "e" | "$" | "0" | "_" | "line"
545576
): VimState {
546577
const { text, cursor, yankBuffer } = state;
547578

548-
// Line operations use special functions
549-
if (motion === "line") {
579+
// Line operations use special functions (dd, cc, yy, d_, c_, y_)
580+
if (motion === "line" || motion === "_") {
550581
if (op === "d") {
551582
const result = deleteLine(text, cursor, yankBuffer);
552583
return completeOperation(state, {
@@ -711,6 +742,9 @@ function tryHandleNavigation(state: VimState, key: string): VimKeyResult | null
711742
return handleKey(state, { cursor: lineStart, desiredColumn: null });
712743
}
713744

745+
case "_":
746+
return handleKey(state, { cursor: moveToFirstNonWhitespace(text, cursor), desiredColumn: null });
747+
714748
case "$":
715749
case "End": {
716750
const { lineStart, lineEnd } = getLineBounds(text, cursor);

0 commit comments

Comments
 (0)