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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ jobs:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: extractions/setup-just@v2
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- run: just install
- run: just check
- run: just compat-unit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
.dev2/
.opencode/
.todo/
.assets/
Expand Down
18 changes: 14 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.ts (414 lines) Plugin entry: intercept registration, action application
index.ts (474 lines) Dual v1 tui/v2 setup entry: intercept registration, action application
editor.ts (28 lines) Host-coordinate horizontal selection bounds, preserving native anchor
v2.ts (243 lines) Experimental v2 TUI facade (host input, commands, state, events)
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
Expand All @@ -84,8 +86,11 @@ test/
normal.test.ts (823) handleNormalKey branches
visual.test.ts (287) handleVisualKey branches
textobject.test.ts (64) resolveTextObject dispatch seam
integration.test.ts (579) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking
integration.test.ts (662) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking
editor.test.ts (118) Host-coordinate boundaries and opaque selection-color forwarding
v2.test.ts (369) Experimental v2 facade contract and lifecycle tests
leader.test.ts (125 lines) Unit tests for leader key matching functions
compat/ Optional real-host Python driver and test-only TUI fixture (not packaged)
```

**Data flow:**
Expand Down Expand Up @@ -155,12 +160,17 @@ Text objects (`iw`/`aw`, the quote/bracket pairs, and the `iq`/`ib` aliases) rou
## Development

```bash
just dev # Launch OpenCode with the plugin (uses OPENCODE_TUI_CONFIG=dev-tui.json)
just dev # Launch v1 with the plugin (uses OPENCODE_TUI_CONFIG=dev-tui.json)
just dev2 # Launch pinned v2/local source with isolated .dev2/ state
bun test # Run characterization tests
just check # Lint + tests (used in GitHub Actions)
just compat-unit # Pure checks for the optional host harness
just compat v1 /absolute/opencode 1.18.33 /canonical/empty/output # Isolated installed-artifact check
```

The `dev-tui.json` config is picked up only by `just dev`. Running `opencode` normally in this directory does not load the plugin.
The `dev-tui.json` config is picked up only by `just dev`. Running `opencode` normally in this directory does not load the plugin. `just dev2` runs `scripts/dev2.ts` with the tested v2 package (or an explicit binary), loads local source through the root `tui.ts` shim, and isolates on-disk settings/history under `.dev2/`. The shim/launcher are not distributed; package installs still resolve `exports["./tui"]`. Visual mode captures the focused editor on entry and re-anchors at the new editor's cursor on the next eligible key after a prompt switch; overlay keys do not change ownership. Horizontal normalization probes `editBuffer.getTextRange()` in host display-cell coordinates and uses lower `editorView` selection calls to retain the renderer's native anchor. The v2 disabled setting is per activation: external storage reconciliation applies on reload, while local `/vim` updates both controller and form guard immediately. `just compat-unit` runs in CI; live-host checks remain optional.

The experimental v2 adapter uses a raw renderer key listener because v2's public keymap does not expose intercepts; see `docs/opencode-v2-poc.md` for configuration and `docs/opencode-v2-strategy.md` for tested alternatives and remaining compatibility gaps.

## Git Workflow

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Added

- Experimental OpenCode v2 TUI compatibility POC using a dual v1/v2 entrypoint and a v2 renderer-key interception adapter. Not yet verified as a supported v2 release.
- Optional installed-package compatibility harness with exact editor-state checks on pinned v1/v2 binaries; lightweight harness regressions run in CI without downloading hosts.
- `just dev2` launches local source with a pinned OpenCode v2 release and separate `.dev2/` settings/history.

### Changed

- Document Git installation, options, and custom leader settings separately for OpenCode v1 and experimental v2.
- Corrected contributor paths for the split Vim engine and documented CI's compatibility-harness checks.

### Fixed

- Visual `h`/`l` now selects through the cursor in both tested hosts, including backward motions, empty buffers and EOF/EOL boundaries, without resetting the native anchor for subsequent word or line motions. Visual mode re-anchors at the new prompt's cursor when the focused editor changes.
- The v2 form leader guard and Vim controller now share a per-activation disabled setting; external TUI toggles take effect on reload, local `/vim` toggles take effect immediately.
- Compatibility checks ignore user tmux configuration, reject JSONC project-config ancestors, and poll boundedly for asynchronous form/permission completion, retaining the last server response in receipts.
- Selection normalization no longer requires an installed `@opentui/core` type dependency; host-owned selection colors are forwarded unchanged.

## [0.18.1] — 2026-09-11

### Changed
Expand Down
35 changes: 25 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@

```bash
just install # install deps
just dev # launch OpenCode with the plugin loaded
just dev # launch OpenCode v1 with the plugin loaded
just dev2 # launch pinned v2 with local source and separate .dev2/ state
just check # run lint + tests
```

Running `opencode` directly in this directory won't load the plugin. You need `just dev`, which sets `OPENCODE_TUI_CONFIG=dev-tui.json`.
Running `opencode` directly in this directory won't load the plugin. `just dev` sets `OPENCODE_TUI_CONFIG=dev-tui.json`. `just dev2` uses npm's package runner (`npx`) for OpenCode 2.0.15 and keeps its on-disk settings/history separate under `.dev2/`. To use an existing v2 binary, run `just dev2 /absolute/path/to/opencode-v2` or set `OPENCODE_V2_BIN`. See [the v2 POC notes](docs/opencode-v2-poc.md#local-development).

## Adding a keybinding

1. Add the key check in the right section of `src/vim.ts` (`handleNormalKey()` for normal mode keys).
1. Add the key check in the matching mode handler under `src/vim/` (`src/vim/normal.ts` for normal mode keys).
2. Return appropriate actions: `{ consume: true, actions: [{ type: "cmd", cmd: "input.some.command" }] }`
3. Add a test in `test/vim.test.ts`.
3. Add a test in the matching file under `test/vim/` (`test/vim/normal.test.ts` for normal mode keys).
4. Run `just check`, then `just dev` to verify.

See `AGENTS.md` for operator+motion combos and other patterns.
Expand All @@ -38,7 +39,7 @@ Types match commit prefixes: `feat`, `fix`, `refactor`, `chore`, `test`, `docs`.
1. Create a branch: `git checkout -b feat/your-feature`
2. Make changes, run `just check` locally. It must pass with zero errors and zero warnings.
3. Push and open a PR against `main`.
4. CI runs `just check`. Warnings are treated as errors — the PR will be blocked until the check is fully clean.
4. CI runs `just check` and `just compat-unit`. Warnings are treated as errors; both checks must pass. Live-host compatibility checks remain manual.
5. PRs are squash-merged. The PR title becomes the commit message on `main`.

## Commit messages
Expand Down Expand Up @@ -67,7 +68,7 @@ Releases are manual.
4. Update link references at the bottom of CHANGELOG.md.
5. Bump version in `package.json` (`npm version X.Y.Z --no-git-tag-version`).
6. Bump `VERSION` in `src/version.ts` to match.
7. Update **all** version tags in `README.md` — the install snippet and the config example both reference a specific version.
7. Update **all** package refs and unreleased wording in `README.md` and this guide for both host versions, including options and leader examples. For the first v2 release, replace the experimental commit refs with the new tag.
8. Run `just check`.
9. Open a PR with the release changes. Title: `Release vX.Y.Z: <one-line summary>`.
10. After CI passes, squash-merge the PR.
Expand All @@ -76,14 +77,28 @@ Releases are manual.

## Distribution

vimcode is installed via git URL in OpenCode's `tui.json`:
Both host versions load the same `./tui` package entry via a Git URL. Pin a tag or commit so upgrades use a new cache entry.

On OpenCode **v1**, use `tui.json`:

```json
{ "plugin": ["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.18.1"] }
```

On OpenCode **v2**, use `cli.json`. v2 support is unreleased; this example pins the tested PR commit, not the v1-only `v0.18.1` release:

```json
{ "plugin": ["vimcode@git+https://github.com/oribarilan/vimcode.git"] }
{
"plugins": [
{
"package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb"
}
]
}
```

Bare names (like `"vimcode"`) trigger npm resolution, which won't work since the package isn't published. The `@git+` prefix tells OpenCode to clone from GitHub.
Bare names (like `"vimcode"`) trigger npm resolution, which won't work since the package isn't published. The `@git+` prefix tells OpenCode to clone from GitHub. See [the README](README.md#install) for options and v2 compatibility limits; [the POC notes](docs/opencode-v2-poc.md#package-configuration) also cover tarball installs.

## Architecture

`src/vim.ts` owns all key handling (pure functions). `src/index.tsx` owns all OpenCode API interaction. See `AGENTS.md` for the full architecture guide.
`src/vim/` owns pure key handling. `src/index.ts` applies actions and registers the shared controller; `src/v2.ts` adapts the v2 host API. See `AGENTS.md` for the full architecture guide.
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@

## Install

Use the instructions for your OpenCode major version. Both versions load the same vimcode package, but their config files and plugin entries differ.

### OpenCode v1

Add to your `tui.json` (or `.opencode/tui.json`):

```json
Expand All @@ -34,21 +38,57 @@ Add to your `tui.json` (or `.opencode/tui.json`):

You'll see a toast when a newer version is available (can be turned off).

### OpenCode v2 (experimental)

v2 support is unreleased. The existing `v0.18.1` release does **not** support v2. To try the tested PR code, pin this commit in your global `cli.json`:

```json
{
"plugins": [
{
"package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb"
}
]
}
```

Cold and warm Git installs were tested on OpenCode 2.0.15 on macOS. Change the pinned ref when updating. See [the v2 POC instructions](docs/opencode-v2-poc.md) for local development, tarball installation, and compatibility limits.

## Configuration

To pass options, use the tuple form in `tui.json`:
### OpenCode v1 options

Use the tuple form in `tui.json`:

```json
{
"plugin": [["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.18.1", { "updateCheck": false }]]
}
```

### OpenCode v2 options

Put `options` alongside `package` in `cli.json`:

```json
{
"plugins": [
{
"package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb",
"options": { "updateCheck": false }
}
]
}
```

### Available options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `updateCheck` | `boolean` | `true` | On startup, check GitHub for new versions (at most once per day). This is the only network request vimcode makes. Set to `false` to disable. |
| `modeIndicator` | `"toast"` \| `"none"` | `"toast"` | How to show the current mode. `"toast"` flashes a brief notification on each switch. `"none"` disables it, relying on cursor shape alone. |
| `startMode` | `"insert"` \| `"normal"` | `"insert"` | Which mode to start in when OpenCode launches. |
| `experimentalV2Leader` | `string` \| `string[]` \| `false` | `"ctrl+x"` | v2 only. Must match the host's leader key. Use `false` or `"none"` if the host leader is disabled. |

## What it does

Expand All @@ -68,13 +108,13 @@ First Escape in insert mode switches to normal - it won't trigger OpenCode's dou

### Leader key

vimcode reads OpenCode's leader key from your `tui.json` keybinds and handles it automatically, no plugin-side config needed.
On OpenCode v1, vimcode reads the leader key from your `tui.json` keybinds automatically. On v2, it cannot read the host's configured leader, so `experimentalV2Leader` must match it. The default `ctrl+x` needs no extra plugin option.

In **normal and visual mode**, the leader key and the follow-up key pass straight through to OpenCode, so leader shortcuts (`<leader>c` for copy, etc.) work as expected.

In **insert mode**, printable leaders (like space) insert their character. Non-printable leaders (like `ctrl+x`) pass through to OpenCode, so leader shortcuts work from any mode.

This allows, for example, to use the popular vim-style `space` leader, set it in your `tui.json`:
For a `space` leader on **v1**, set it in `tui.json`:

```json
{
Expand All @@ -84,7 +124,21 @@ This allows, for example, to use the popular vim-style `space` leader, set it in
}
```

Note that OpenCode defaults the leader to `ctrl+x`.
On **v2**, set both the host keybind and the plugin option in `cli.json`:

```json
{
"keybinds": { "leader": "space" },
"plugins": [
{
"package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb",
"options": { "experimentalV2Leader": "space" }
}
]
}
```

If they do not match, vimcode can swallow leader shortcuts or printable input.

### Platform notes

Expand Down Expand Up @@ -207,7 +261,7 @@ Configurable key bindings are next once the core vim coverage stabilizes.

## How it works

vimcode registers a key intercept on every prompt keypress. A pure handler in `src/vim.ts` takes the current mode and key, returns a list of actions (move cursor, delete word, switch mode, etc.) without touching the plugin API. `src/index.ts` applies those actions through `@opentui/keymap` commands.
On v1, vimcode registers a host key intercept. The experimental v2 adapter uses a renderer key listener instead. Pure handlers in `src/vim/` take the current mode and key and return actions without touching the plugin API. `src/index.ts` applies those actions through editor methods and host commands; `src/v2.ts` adapts the v2 context to the shared controller.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": ["src/**", "test/**"]
"includes": ["src/**", "test/**", "scripts/**", "tui.ts"]
},
"formatter": {
"enabled": true,
Expand Down
Loading
Loading