Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ node_modules/
dist/
.opencode/
.todo/
.assets/
package-lock.json
bun.lock
35 changes: 31 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,37 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i
- The plugin `package.json` needs `exports: { "./tui": "./src/index.ts" }` — the loader checks `./tui`, not `.`.
- `dispatchCommand()` from inside a `key:before` intercept doesn't work for cursor movement. Wrap in `setTimeout(..., 0)` to break out of the intercept stack.
- `registerLayer` with `activeWhen` using SolidJS signals requires `reactiveMatcherFromSignal` from `@opentui/keymap/solid`. Plain `() => signal()` doesn't trigger re-evaluation. We chose intercepts instead of layers to avoid this.
- The plugin API exposes no cursor position. `api.prompt.current.input` gives text content only. No `setCursor`, no `getSelection`. This limits what vim operations we can implement.
- **No external runtime imports in distributed plugins.** OpenCode's Bun runtime module plugin (`onResolve` hooks for `solid-js`, `@opentui/solid`, etc.) doesn't intercept imports from files loaded from `~/.cache/opencode/packages/`. Any import from `solid-js` or `@opentui/solid` fails with `Cannot find module`. Use only the `api` parameter and local modules. Mode feedback uses `api.ui.toast()` instead of a slot indicator. This limitation affects all git/npm-installed TUI plugins, not just vimcode.

### Editor widget API

`api.renderer.currentFocusedEditor` (same object as `currentFocusedRenderable`) exposes the full underlying Textarea widget. This is not part of the documented plugin API but is stable and available at runtime. The current codebase only uses `plainText`, `insertText()`, and `editorView` — most of the surface below is untapped.

**Top-level properties (read/write):**
- `cursorOffset: number` — absolute cursor position, readable and writable
- `visualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }` — full cursor coordinates (read-only in practice)
- `cursorStyle: { style: "block" | "line" | "underline" | "default", blinking: boolean }` — set directly, no DECSCUSR escape needed
- `plainText: string` — buffer content
- `selectionBg: RGBA`, `selectionFg: RGBA` — custom selection highlight colors

**Top-level methods:**
- `moveCursorLeft/Right/Up/Down()` — direct cursor movement
- `setSelection(start, end)`, `setSelectionInclusive(start, end)`, `clearSelection()` — selection control
- `gotoVisualLineEnd()`, `gotoLineEnd()` — line boundary jumps
- `insertText(text)` — insert at cursor

**editorView methods (lower-level):**
- `setCursorByOffset(n)` — position cursor by offset
- `getNextWordBoundary()`, `getPrevWordBoundary()` — word boundary detection (enables proper `e` vs `w`)
- `getEOL()`, `getVisualSOL()`, `getVisualEOL()` — line boundary info
- `getLineInfo()`, `getLogicalLineInfo()` — line metadata
- `getCursor()`, `getVisualCursor()`, `getText()` — read state
- `getSelectedText()`, `deleteSelectedText()` — selection operations
- `moveUpVisual()`, `moveDownVisual()` — visual line movement
- `setSelection()`, `resetSelection()`, `hasSelection()` — selection management

This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, and accurate line operations feasible. The current `setTimeout` + `dispatchCommand` approach can be replaced with direct widget manipulation for most operations.

## Architecture

```
Expand Down Expand Up @@ -81,9 +109,8 @@ To add a new motion that works with operators:
### Known limitations

- **`g` fires immediately as `input.buffer.home`** — should wait for a second `g` (needs sequence state). Single `g` = go to top, which is wrong for vim.
- **`lineTracker` drifts** — only j/k/G/g/o update it. Clicks, arrow keys, word motions don't. `yy` can yank the wrong line.
- **No cursor access** — the plugin API doesn't expose cursor position. Text objects (`ciw`, `di"`) are not feasible. Character-wise visual mode works via `input.select.*` commands but has no cursor position feedback. Input text is read from `api.renderer.currentFocusedEditor.plainText` (the TUI plugin API has no `api.prompt`).
- **`setTimeout` dispatch** — commands are deferred to avoid re-entrancy. Multi-command sequences (like `O` = home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec.
- **`lineTracker` drifts** — only j/k/G/g/o update it. Clicks, arrow keys, word motions don't. `yy` can yank the wrong line. Solvable now via `cursorOffset` + `visualCursor` — the tracker can be replaced with direct cursor reads.
- **`setTimeout` dispatch** — commands are deferred to avoid re-entrancy. Multi-command sequences (like `O` = home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec. Many of these can now be replaced with direct widget manipulation (e.g., setting `cursorOffset`, calling `insertText`).

## Development

Expand Down
55 changes: 55 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Backlog

Prioritized list of improvements for vimcode. Items within each category are ordered by priority.

## Stability / Bug fixes

1. **Replace `lineTracker` with direct cursor reads.** `lineTracker` drifts whenever the cursor moves by means other than j/k/G/g/o (clicks, arrow keys, word motions). This causes `yy` to yank the wrong line. `visualCursor.logicalRow` gives the real line — read it directly.

2. **Fix `gg` requiring two keypresses.** Single `g` fires `input.buffer.home` immediately. Real vim waits for a second `g`. Add pending-key state for `g` with a timeout or second-key check, similar to how `r` already works with `pendingChar`.

3. **Fix `e` behaving identically to `w`.** `editorView.getNextWordBoundary()` is available. Use it to implement proper end-of-word motion that stops at the last character of the current word rather than the first character of the next.

4. **Eliminate `setTimeout` command dispatch for operations that can use direct manipulation.** Multi-command sequences like `O` (home + newline + up) depend on setTimeout ordering. Replace with direct buffer manipulation (`cursorOffset` writes, `insertText()`) where possible. Keep setTimeout only for commands that genuinely need `dispatchCommand` (submit, undo/redo, history navigation).

## Cleaner implementations

1. ~~**Set cursor style via `cursorStyle` property instead of DECSCUSR escapes.**~~ Done.

2. **Replace `dispatchCommand`-based motions with direct cursor manipulation.** Motions like h/l/j/k/w/b can use `moveCursorLeft/Right/Up/Down()` or write `cursorOffset` directly instead of dispatching `input.move.*` commands through setTimeout. Reduces latency and eliminates re-entrancy concerns.

3. **Replace selection commands with `setSelection`/`setSelectionInclusive`.** Visual mode currently dispatches `input.select.*` commands. The widget exposes `setSelection(start, end)` and `setSelectionInclusive(start, end)` — use these for immediate, accurate selections.

4. **Replace `yankSelection` setTimeout with synchronous read.** The current `yankSelection` action defers to let select commands finish. With direct `setSelection` + `getSelectedText()`, the yank can happen synchronously.

5. **Remove `PromptAccess` abstraction.** `getLine(n)` and `getLineCount()` split `plainText` on every call. With `cursorOffset` and `visualCursor` available, most callers don't need line-based access. Where they do, read `plainText` once and split.

## New features

1. **Text objects (`ciw`, `diw`, `yiw`, `ci"`, `di"`, `da(`, etc.).** Now feasible with cursor position access. Read `plainText` + `cursorOffset`, compute the object range in pure logic, apply the edit via `setSelection` + `deleteSelectedText` or direct text manipulation. Start with word and quote objects, then add bracket/paren.

2. **Visual-line mode (`V`).** The widget's `getLineInfo()` and `setSelection()` make line-wise selection straightforward. Extend the existing visual mode with a `visual-line` variant.

3. **`dG`/`cG` — delete/change to buffer end.** `yG` already works. Delete and change variants need the same range calculation plus a content write.

4. **Proper `gg` as go-to-line.** Once `g` waits for a second keypress, `gg` goes to buffer start and `{n}G` goes to line n.

5. **`/vim` toggle command.** Register a slash command via `api.keymap.registerLayer({ commands: [...] })` that toggles vim mode on/off. Persist the setting with `api.kv`. Lets users disable vim without editing config.

6. **Custom keymaps.** User-configurable key remapping per mode via `tui.json` options. Common requests: `jk`/`kj` to exit insert mode, `Y` mapped to `y$`. Needs multi-key sequence support with a configurable timeout.

7. **Pending key display.** Show partial key sequences (like `d` waiting for a motion, or the count accumulator) somewhere visible. Currently these are invisible — the user doesn't know vimcode is waiting for more input.

8. **Yank flash.** Brief highlight on yanked text using `selectionBg`/`selectionFg` with a short timer (200-300ms). Gives visual confirmation like Neovim's `vim.highlight.on_yank()`.

9. **Completion-aware j/k.** When the cursor follows `@` or `/` (autocomplete triggers), normal-mode j/k should navigate the completion popup rather than move the cursor.

10. **Persistent mode indicator.** Replace the fading toast with a persistent visual. Blocked by the no-external-imports limitation for slot-based UI, but could potentially use `api.renderer.keyInput.processParsedKey()` or find another approach.

## Polish

1. **Normal-mode cursor clamping.** In vim, normal-mode cursor can't sit past the last character on a line. Currently the cursor can land on the newline position after motions like `$` or `A` followed by Escape.

2. **`i` should not advance cursor on Escape.** Entering and exiting insert mode without typing should leave the cursor where it was (or move left one from `a`/`A`). Currently inconsistent.

3. **`p` paste positioning.** Vim's `p` pastes after the cursor for character-wise yanks and below the current line for line-wise yanks. Current implementation always pastes at cursor via `prompt.paste`.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Changed

- Cursor shape now uses the editor widget's `cursorStyle` property instead of writing DECSCUSR escape sequences to stdout. Works in terminals that don't support DECSCUSR (e.g. macOS Terminal.app).

## [0.9.0] — 2026-05-29

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ First Escape in insert mode switches to normal - it won't trigger OpenCode's dou

Clipboard (`y`, `yy`, `p`) uses the system clipboard: `pbcopy` on macOS, `clip.exe` on Windows, `xclip` on Linux. Linux users need `xclip` installed (`apt install xclip` or equivalent). If the clipboard tool is missing, yank/paste still works within the session via an internal register.

Cursor shape (block in normal, bar in insert) needs a terminal that supports DECSCUSR escape sequences. Most modern terminals do: iTerm2, Ghostty, Alacritty, Windows Terminal, Kitty. Older macOS Terminal.app may not respond.
Cursor shape (block in normal, bar in insert) works across all terminals. No special terminal support required.

The plugin checks GitHub for new versions once per day on startup. No other network requests, no telemetry.

Expand Down
23 changes: 12 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,18 @@ const plugin: TuiPluginModule = {
}
}

// The Textarea's renderCursor() hardcodes "block" on every frame.
// api.renderer.addPostProcessFn would be ideal but isn't reliably
// available for git-installed plugins. Instead, re-apply the correct
// DECSCUSR escape on a short interval. 4 bytes at 100ms is negligible.
const cursorInterval = setInterval(() => {
process.stdout.write(state.mode === "insert" ? "\x1b[6 q" : "\x1b[2 q");
}, 100);
api.lifecycle?.onDispose?.(() => {
clearInterval(cursorInterval);
process.stdout.write("\x1b[2 q");
});
function syncCursorStyle() {
const editor = api.renderer?.currentFocusedEditor;
if (!editor) return;
editor.cursorStyle = { style: state.mode === "insert" ? "line" : "block", blinking: true };
}

// The Textarea resets cursorStyle during rendering, so re-apply on a
// short interval. Setting a property is cheaper than the previous
// approach of writing DECSCUSR escape sequences to stdout, and works
// in terminals that don't support DECSCUSR (e.g. macOS Terminal.app).
const cursorInterval = setInterval(syncCursorStyle, 100);
api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval));

if (options?.updateCheck !== false) {
checkForUpdate((opts) => api.ui?.toast?.(opts), api.kv);
Expand Down
13 changes: 3 additions & 10 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,15 +657,8 @@ describe("plugin init", () => {
kv: {}, // empty object — the scenario that crashed v0.7.0
};

// Should not throw. The cursor interval writes to stdout,
// so we stub it to avoid noise in test output.
const origWrite = process.stdout.write;
process.stdout.write = () => true;
try {
// biome-ignore lint/suspicious/noExplicitAny: mock API doesn't match full plugin types
await plugin.tui(api as any, undefined, undefined as any);
} finally {
process.stdout.write = origWrite;
}
// Should not throw with a sparse mock API.
// biome-ignore lint/suspicious/noExplicitAny: mock API doesn't match full plugin types
await plugin.tui(api as any, undefined, undefined as any);
});
});
Loading