Skip to content

feat: add first-non-blank motions for ^, _, and I - #58

Open
jtams wants to merge 4 commits into
oribarilan:mainfrom
jtams:feat/first-non-blank-motions
Open

jtams wants to merge 4 commits into
oribarilan:mainfrom
jtams:feat/first-non-blank-motions

Conversation

@jtams

@jtams jtams commented Jul 25, 2026

Copy link
Copy Markdown

This PR adds support for the I, _, and ^ Vim motions, all of which operate on the first non-blank character of a line.

^ was previously mapped to Home, which moves the cursor to the beginning of the line rather than the first non-blank character. This PR fixes that behavior.

It also adds support for I and _. The _ motion supports counts; for example, 3_ moves the cursor to the first non-blank character of the third line, equivalent to 2j^ in Vim.

Also adds tests covering the new behavior.

@jtams

jtams commented Jul 25, 2026

Copy link
Copy Markdown
Author

This PR resolves #18

@oribarilan

Copy link
Copy Markdown
Owner

Heads up: #62 just merged, which restructured the vim engine from a single src/vim.ts into a modular src/vim/ tree. This PR will need a rebase onto main, and the code you're touching has moved:

  • motion handling → src/vim/normal.ts
  • motion tables (MOTIONS, SELECT_MOTIONS, DELETE_MOTION) → src/vim/tables.ts
  • tests → test/vim/normal.test.ts

Also note CI is now stricter: Biome runs with noExplicitAny, noConsole, and noNonNullAssertion as errors via just check. Happy to help with the rebase if useful.

@jtams
jtams force-pushed the feat/first-non-blank-motions branch from fb1a334 to 296f116 Compare September 2, 2026 23:50
@jtams

jtams commented Sep 2, 2026 •

Copy link
Copy Markdown
Author

I rebased onto main and ported the changes to the new modular vim engine. Thanks for the heads up.

Comment thread src/index.ts Outdated
const lineStart = text.lastIndexOf("\n", target - 1) + 1;

editor.cursorOffset = lineStart;
for (let i = lineStart; i < target; i++) editor.moveCursorRight();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding these motions and updating the PR after the refactor!

I found a regression in this loop. When text is selected, moveCursorRight() jumps to the selection’s end instead of moving one character.

To reproduce, start with hello world, put the cursor on e, then press ve<Esc>x.

Vim leaves hell world. With this PR, I get helloworld in OpenCode 1.18.21. It deletes the space instead of the o.

Could we set the cursor position without using selection-aware movement here? A test for this sequence would help catch it.

Comment thread src/vim/normal.ts Outdated
}

function moveToFirstNonBlank(actions: Action[], line: string) {
actions.push({ type: "cmd", cmd: "input.line.home" });

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input.line.home has a surprising behavior in OpenTUI. At the start of a later line, it moves to the end of the previous line.

For example, start with:

previous
next

Put the cursor on n, then press Ix<Esc>. I get previousx on the first line instead of xnext on the second.

Can we keep this move on the current line? Please add a test for this case too.

Comment thread src/vim/normal.ts
Comment on lines +218 to +220
const target = firstNonBlankOnLine(text, offset);
const start = Math.min(offset, target);
const end = Math.max(offset, target) - 1;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tabs expose a mismatch here. The helper counts positions in the text string, but the editor's cursor position includes the tab's display width.

Start with a tab and two spaces before hello world. Put the cursor on w, then press d^.

Vim leaves "\t world". In OpenCode 1.18.21, this PR leaves "\t world". One indentation space gets deleted.

Can we make sure both positions use the same units before calculating the range? A test that checks the resulting text would catch this.

Comment thread src/vim/text.ts Outdated

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;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skips the first line when the buffer starts with a newline. JavaScript's lastIndexOf("\n", -1) still checks position zero, so start becomes 1 instead of 0.

With an empty line followed by hello, pressing ^ on the empty line jumps to h. It should stay on the empty line.

The same expression in cursorTo also makes gg skip that first line.

Could we handle offset zero explicitly in both places and add a test for a buffer that starts with a newline?

@jtams
jtams force-pushed the feat/first-non-blank-motions branch from 296f116 to e468604 Compare September 20, 2026 22:38
@jtams

jtams commented Sep 21, 2026

Copy link
Copy Markdown
Author

Rebased onto main again and addressed those issues. I also added tests like you recommended.

Replaced selection-aware movement with direct cursor positioning for visual motions.

Replaced input.line.home for I with direct current-line positioning.

Normalized vim string offsets to OpenTUI display columns for tabs before applying cursor, selection, and delete actions.

And changed it to handle offset zero for initial blank lines.

Also fixed direct visual h/l selection extension and I after tab indentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants