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
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.15.3] — 2026-07-01

### Fixed
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 All @@ -139,6 +142,7 @@ Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3
|-----|--------|
| `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 @@ -157,7 +161,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
94 changes: 90 additions & 4 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 Expand Up @@ -56,7 +57,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 @@ -69,7 +69,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 @@ -79,7 +78,6 @@ 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",
};
Expand Down Expand Up @@ -155,6 +153,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 All @@ -169,7 +168,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 Expand Up @@ -345,6 +344,50 @@ 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.pendingOp && key === "_") {
const n = consumeCount(state);

if (state.pendingOp === "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 (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}

// ^ is an exclusive characterwise motion to the first non-blank character.
if (state.pendingOp && key === "^") {
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 (state.pendingOp === "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 (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}

// Pending operator + e (end-of-word needs special handling)
if (state.pendingOp && key === "e") {
const n = consumeCount(state);
Expand Down Expand Up @@ -420,6 +463,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 @@ -490,6 +542,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 @@ -572,6 +630,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 Expand Up @@ -651,6 +717,26 @@ function pushN(actions: Action[], cmd: string, n: number) {
for (let i = 0; i < n; i++) actions.push({ type: "cmd", cmd });
}

function moveToFirstNonBlank(actions: Action[], line: string, home = "input.line.home", right = "input.move.right") {
actions.push({ type: "cmd", cmd: home });
const firstNonBlank = line.search(/[^ \t]/);
if (firstNonBlank !== -1) pushN(actions, right, firstNonBlank);
}

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]/);
return start + Math.max(0, firstNonBlank);
}

function isInputEmpty(prompt: PromptAccess): boolean {
return prompt.getLineCount() === 1 && prompt.getLine(0) === "";
}
Loading