Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,31 +57,31 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.ts (408 lines) Plugin entry: intercept registration, action application
index.ts (430 lines) Plugin entry: intercept registration, action application
vim/ Pure vim engine (thin barrel re-exports the public surface):
index.ts (7 lines) Barrel — public surface only. No export *, no internals.
types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess
text.ts (210 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange
tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal)
types.ts (58 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess
text.ts (224 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange
tables.ts (32 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal)
textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib)
util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN
util.ts (20 lines) State-agnostic primitives: translateKey, PASS, pushN
state.ts (76 lines) VimState lifecycle + transitions
insert.ts (32 lines) handleInsertKey
normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty)
visual.ts (104 lines) handleVisualKey
normal.ts (445 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty, moveToFirstNonBlank)
visual.ts (112 lines) handleVisualKey
leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar
clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe)
version.ts (46 lines) Version constant, GitHub update check (cached daily)
test/
support.ts (33 lines) Shared assertion helpers + ev()
support.ts (37 lines) Shared assertion helpers + ev()
fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt
vim/ Per-module engine tests mirroring src/vim/:
text.test.ts (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units
text.test.ts (408) endOfWord, charKind, currentLineRange, firstNonBlankOnLine, wordRange, bracketRange, quoteRange, any* units
state.test.ts (70) createVimState, toggleVimMode
util.test.ts (31) translateKey
insert.test.ts (92) handleInsertKey
normal.test.ts (823) handleNormalKey branches
visual.test.ts (287) handleVisualKey branches
util.test.ts (35) translateKey
insert.test.ts (108) handleInsertKey
normal.test.ts (981) handleNormalKey branches
visual.test.ts (324) handleVisualKey branches
textobject.test.ts (64) resolveTextObject dispatch seam
integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync
leader.test.ts (125 lines) Unit tests for leader key matching functions
Expand All @@ -105,7 +105,8 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they
- `{ type: "insertText", text: string }` — inserts text at cursor via `editor.insertText()`
- `{ type: "yankSelection" }` — reads selected text from the focused editor, stores in yank register and clipboard
- `{ type: "clearSelection" }` — clears the textarea's selection via `editorView.resetSelection()`
- `{ type: "cursorTo", offset: number }` — sets `editor.cursorOffset` directly
- `{ type: "cursorTo", offset: number }` — moves the textarea cursor to a buffer offset
- `{ type: "cursorLeft" }` — moves the textarea cursor left once
- `{ type: "selectRange", start: number, end: number }` — calls `editor.setSelectionInclusive(start, end)`
- `{ type: "deleteRange", start: number, end: number }` — deletes text between inclusive offsets via `editBuffer.deleteRange()`. Saves a snapshot for single-step undo (see below).
- `{ type: "undo" }` — if an undo snapshot exists (from a `deleteRange`), restores the full buffer from it. Otherwise falls back to `dispatchCommand("input.undo")`.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Added

- Added `_` and `I` support with Vim's first-non-blank behavior.

### Fixed

- `^` now moves to the first non-blank character instead of the start of the line.
- Escape after `I` now moves left from the actual textarea cursor instead of misplacing the cursor after indentation.

## [0.18.0] — 2026-09-02

### Added
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ The plugin checks GitHub for new versions once per day on startup. No other netw
|-----|--------|
| `h` `j` `k` `l` | Left, down, up, right |
| `w` `b` `e` | Word forward, backward, end of word |
| `0` `^` | Line start |
| `0` | Line start |
| `^` | First non-blank character |
| `_` | First non-blank character on the count-th line down (`4_` = `3j^`) |
| `$` | Line end |
| `gg` | Buffer start |
| `G` | Buffer end |
Expand All @@ -124,7 +126,8 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving
| `de` `ce` `ye` | To end of word |
| `d$` `c$` `y$` | To end of line |
| `d0` `c0` `y0` | To start of line |
| `d^` `c^` `y^` | To start of line |
| `d^` `c^` `y^` | To first non-blank character |
| `d_` `c_` `y_` | Operate on whole line(s), like `dd` `cc` `yy` |
| `dh` `ch` `yh` | Character left |
| `dl` `cl` `yl` | Character right |
| `dj` `cj` `yj` | Current + line below |
Expand Down Expand Up @@ -155,6 +158,7 @@ So `diw`, `ci"`, `da(`, `yi{`, `vib`, `ciq` all work. `iw` covers the run under
|-----|--------|
| `i` | Insert at cursor |
| `a` | Insert after cursor |
| `I` | Insert at first non-blank character |
| `A` | Insert at end of line |
| `o` | Open line below |
| `O` | Open line above |
Expand All @@ -173,7 +177,7 @@ Press `v` in normal mode to enter character-wise visual mode. Press `V` to selec
| `V` | Select current line |
| `Escape` `v` | Exit visual mode |

All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `$` `G`, with counts.
All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `^` `_` `$` `G`, with counts.

### Other

Expand Down
24 changes: 23 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,29 @@ const plugin: TuiPluginModule = {
}
case "cursorTo": {
const editor = api.renderer?.currentFocusedEditor;
if (editor) editor.cursorOffset = action.offset;
if (editor?.moveCursorRight) {
const text = editor.plainText ?? "";
const target = Math.min(Math.max(action.offset, 0), text.length);
const lineStart = text.lastIndexOf("\n", target - 1) + 1;

editor.cursorOffset = lineStart;
for (let i = lineStart; i < target; i++) editor.moveCursorRight();
editor.getLayoutNode?.().markDirty?.();
api.renderer?.requestRender?.();
} else if (editor) {
editor.cursorOffset = action.offset;
}
break;
}
case "cursorLeft": {
const editor = api.renderer?.currentFocusedEditor;
if (editor?.moveCursorLeft) {
editor.moveCursorLeft();
editor.getLayoutNode?.().markDirty?.();
api.renderer?.requestRender?.();
} else if (editor) {
editor.cursorOffset = Math.max(0, (editor.cursorOffset ?? 0) - 1);
}
break;
}
case "selectRange": {
Expand Down
2 changes: 1 addition & 1 deletion src/vim/insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, pro
// unless at position 0 or start of line.
const offset = prompt.getCursorOffset();
if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") {
actions.push({ type: "cursorTo", offset: offset - 1 });
actions.push({ type: "cursorLeft" });
}
actions.push({ type: "mode", mode: "normal" });
return { consume: true, actions };
Expand Down
69 changes: 68 additions & 1 deletion src/vim/normal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { consumeCount, enterInsert, resetPending } from "./state";
import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables";
import { currentLineRange, endOfWord } from "./text";
import { currentLineRange, endOfWord, firstNonBlankOnLine } from "./text";
import { resolveTextObject } from "./textobject";
import type { Action, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types";
import { PASS, pushN } from "./util";
Expand Down Expand Up @@ -187,6 +187,52 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return finishUndoableChange(actions);
}

// `_` is linewise when used with an operator, like `dd`/`cc`/`yy`.
if (state.pending.kind === "operator" && key === "_") {
const op = state.pending.op;
const n = consumeCount(state);

if (op === "y") {
const cursorLine = prompt.getCursorLine();
const lines: string[] = [];
for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i));
const text = `${lines.join("\n")}\n`;
state.yankRegister = text;
actions.push({ type: "yank", text });
resetPending(state);
return { consume: true, actions };
}

pushN(actions, "input.delete.line", n);
if (op === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}

// `^` is an exclusive characterwise motion to the first non-blank character.
if (state.pending.kind === "operator" && key === "^") {
const op = state.pending.op;
consumeCount(state);
const text = prompt.getPlainText();
const offset = prompt.getCursorOffset();
const target = firstNonBlankOnLine(text, offset);
const start = Math.min(offset, target);
const end = Math.max(offset, target) - 1;

if (op === "y") {
const yanked = text.slice(start, end + 1);
state.yankRegister = yanked;
actions.push({ type: "yank", text: yanked });
resetPending(state);
return { consume: true, actions };
}

if (start <= end) actions.push({ type: "deleteRange", start, end });
if (op === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}

// Pending operator + e (end-of-word needs special handling)
if (state.pending.kind === "operator" && key === "e") {
const op = state.pending.op;
Expand Down Expand Up @@ -252,6 +298,15 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

if (key === "^" || key === "_") {
const n = consumeCount(state);
actions.push({
type: "cursorTo",
offset: firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0),
});
return { consume: true, actions };
}

// Standalone motions
if (key in MOTIONS) {
const n = consumeCount(state);
Expand Down Expand Up @@ -322,6 +377,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

if (key === "I") {
moveToFirstNonBlank(actions, prompt.getLine(prompt.getCursorLine()));
enterInsert(state, actions);
return { consume: true, actions };
}

if (key === "A") {
actions.push({ type: "cmd", cmd: "input.line.end" });
enterInsert(state, actions);
Expand Down Expand Up @@ -376,3 +437,9 @@ function applyOperatorRange(
function isInputEmpty(prompt: PromptAccess): boolean {
return prompt.getLineCount() === 1 && prompt.getLine(0) === "";
}

function moveToFirstNonBlank(actions: Action[], line: string) {
actions.push({ type: "cmd", cmd: "input.line.home" });
const firstNonBlank = line.search(/[^ \t\r]/);
if (firstNonBlank !== -1) pushN(actions, "input.move.right", firstNonBlank);
}
3 changes: 0 additions & 3 deletions src/vim/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export const MOTIONS: Record<string, string> = {
w: "input.word.forward",
b: "input.word.backward",
"0": "input.line.home",
"^": "input.line.home",
$: "input.line.end",
G: "input.buffer.end",
};
Expand All @@ -19,7 +18,6 @@ export const SELECT_MOTIONS: Record<string, string> = {
w: "input.select.word.forward",
b: "input.select.word.backward",
"0": "input.select.line.home",
"^": "input.select.line.home",
$: "input.select.line.end",
G: "input.select.buffer.end",
};
Expand All @@ -29,7 +27,6 @@ export const DELETE_MOTION: Record<string, string> = {
b: "input.delete.word.backward",
$: "input.delete.to.line.end",
"0": "input.delete.to.line.start",
"^": "input.delete.to.line.start",
h: "input.backspace",
l: "input.delete",
};
14 changes: 14 additions & 0 deletions src/vim/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export function currentLineRange(text: string, offset: number): { start: number;
return { start, end: newline === -1 ? text.length - 1 : newline };
}

export function firstNonBlankOnLine(text: string, offset: number, linesDown = 0): number {
const safeOffset = Math.min(Math.max(offset, 0), text.length);
let start = text.lastIndexOf("\n", safeOffset - 1) + 1;
for (let i = 0; i < linesDown; i++) {
const newline = text.indexOf("\n", start);
if (newline === -1) break;
start = newline + 1;
}
const lineEnd = text.indexOf("\n", start);
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
const firstNonBlank = line.search(/[^ \t\r]/);
return start + Math.max(0, firstNonBlank);
}

// Inclusive [start, end] offsets for the `iw`/`aw` text object under the
// cursor. A "word" is a run of one charKind (word/punct/space); runs never
// cross a newline. `around` extends past a word/punct run to its trailing
Expand Down
1 change: 1 addition & 0 deletions src/vim/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type Action =
| { type: "saveUndoSnapshot" }
| { type: "undo" }
| { type: "cursorTo"; offset: number }
| { type: "cursorLeft" }
| { type: "selectRange"; start: number; end: number };

export type HandlerResult = {
Expand Down
1 change: 1 addition & 0 deletions src/vim/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function translateKey(ev: KeyEvent): string {
if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase();
else if (ev.name === "4") key = "$";
else if (ev.name === "6") key = "^";
else if (ev.name === "-") key = "_";
else if (ev.name === "[") key = "{";
else if (ev.name === "]") key = "}";
}
Expand Down
10 changes: 9 additions & 1 deletion src/vim/visual.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state";
import { SELECT_MOTIONS } from "./tables";
import { endOfWord } from "./text";
import { endOfWord, firstNonBlankOnLine } from "./text";
import { resolveTextObject } from "./textobject";
import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types";
import { PASS, pushN } from "./util";
Expand Down Expand Up @@ -81,6 +81,14 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

if (key === "^" || key === "_") {
const n = consumeCount(state);
const target = firstNonBlankOnLine(prompt.getPlainText(), prompt.getCursorOffset(), key === "_" ? n - 1 : 0);
actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target });
actions.push({ type: "cursorTo", offset: target });
return { consume: true, actions };
}

// Motions extend selection
if (key in SELECT_MOTIONS) {
pushN(actions, SELECT_MOTIONS[key], consumeCount(state));
Expand Down
4 changes: 4 additions & 0 deletions test/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export function cursorTos(actions: Action[]): number[] {
return actions.filter((a): a is Extract<Action, { type: "cursorTo" }> => a.type === "cursorTo").map((a) => a.offset);
}

export function cursorLefts(actions: Action[]): number {
return actions.filter((a) => a.type === "cursorLeft").length;
}

export function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> {
return actions
.filter((a): a is Extract<Action, { type: "deleteRange" }> => a.type === "deleteRange")
Expand Down
26 changes: 21 additions & 5 deletions test/vim/insert.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { createVimState, handleInsertKey, type PromptAccess, type VimState } from "../../src/vim";
import { createVimState, handleInsertKey, handleNormalKey, type PromptAccess, type VimState } from "../../src/vim";
import { mockPrompt } from "../fixtures";
import { cmds, cursorTos, ev } from "../support";
import { cmds, cursorLefts, ev } from "../support";

let state: VimState;

Expand Down Expand Up @@ -57,12 +57,28 @@ describe("handleInsertKey", () => {
};
const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt);
expect(r.consume).toBe(true);
expect(cursorTos(r.actions)).toEqual([4]);
expect(cursorLefts(r.actions)).toBe(1);
});

it("I then escape moves left from the textarea cursor", () => {
const prompt: PromptAccess = {
getLine: () => " test test test",
getLineCount: () => 1,
getCursorLine: () => 0,
getCursorOffset: () => 7,
getPlainText: () => " test test test",
};

const enterInsert = handleNormalKey(state, "I", ev("i", { shift: true }), prompt);
expect(enterInsert.actions).toContainEqual({ type: "mode", mode: "insert" });

const leaveInsert = handleInsertKey(state, "escape", ev("escape"), prompt);
expect(cursorLefts(leaveInsert.actions)).toBe(1);
});

it("escape at position 0 does not move cursor", () => {
const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt);
expect(cursorTos(r.actions)).toEqual([]);
expect(cursorLefts(r.actions)).toBe(0);
});

it("escape at start of line does not move cursor", () => {
Expand All @@ -74,7 +90,7 @@ describe("handleInsertKey", () => {
getPlainText: () => "hello world\nsecond line",
};
const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt);
expect(cursorTos(r.actions)).toEqual([]);
expect(cursorLefts(r.actions)).toBe(0);
});

it("ctrl+o enters normal mode with oneShotNormal flag", () => {
Expand Down
Loading