From d91c253ade965cf1af40535ce82c6de37d676327 Mon Sep 17 00:00:00 2001 From: ori Date: Wed, 30 Sep 2026 21:01:51 +0300 Subject: [PATCH 1/4] feat: add experimental OpenCode v2 support --- .gitignore | 1 + AGENTS.md | 16 +- CHANGELOG.md | 10 + CONTRIBUTING.md | 5 +- biome.json | 2 +- docs/opencode-v2-poc.md | 73 +++++++ docs/opencode-v2-strategy.md | 118 +++++++++++ docs/opencode-v2-upstream-drafts.md | 19 ++ justfile | 12 ++ scripts/dev2.ts | 51 +++++ src/editor.ts | 20 ++ src/index.ts | 48 ++++- src/v2.ts | 239 ++++++++++++++++++++++ test/compat/.gitignore | 1 + test/compat/cases.py | 184 +++++++++++++++++ test/compat/driver.py | 289 +++++++++++++++++++++++++++ test/compat/fixture/package.json | 8 + test/compat/fixture/tui.ts | 262 ++++++++++++++++++++++++ test/compat/run.py | 105 ++++++++++ test/compat/test_results.py | 94 +++++++++ test/editor.test.ts | 37 ++++ test/integration.test.ts | 81 ++++++++ test/v2.test.ts | 298 ++++++++++++++++++++++++++++ tui.ts | 2 + 24 files changed, 1962 insertions(+), 13 deletions(-) create mode 100644 docs/opencode-v2-poc.md create mode 100644 docs/opencode-v2-strategy.md create mode 100644 docs/opencode-v2-upstream-drafts.md create mode 100644 scripts/dev2.ts create mode 100644 src/editor.ts create mode 100644 src/v2.ts create mode 100644 test/compat/.gitignore create mode 100644 test/compat/cases.py create mode 100644 test/compat/driver.py create mode 100644 test/compat/fixture/package.json create mode 100644 test/compat/fixture/tui.ts create mode 100644 test/compat/run.py create mode 100644 test/compat/test_results.py create mode 100644 test/editor.test.ts create mode 100644 test/v2.test.ts create mode 100644 tui.ts diff --git a/.gitignore b/.gitignore index ad85280..5200051 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.dev2/ .opencode/ .todo/ .assets/ diff --git a/AGENTS.md b/AGENTS.md index 6e74784..7004d71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (450 lines) Dual v1 tui/v2 setup entry: intercept registration, action application + editor.ts (20 lines) Visual character selection normalization at the host boundary + v2.ts (239 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 @@ -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 (660) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking + editor.test.ts (37) Inclusive ranges without resetting the native selection anchor + v2.test.ts (298) 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:** @@ -155,12 +160,15 @@ 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"]`. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc206f..5fb3123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ 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. +- `just dev2` launches local source with a pinned OpenCode v2 release and separate `.dev2/` settings/history. + +### Fixed + +- Visual `h`/`l` now selects through the cursor in both tested hosts, including backward motions and crossing the anchor, without resetting the anchor for subsequent word or line motions. + ## [0.18.1] — 2026-09-11 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04fd034..a97cf10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,12 @@ ```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 diff --git a/biome.json b/biome.json index 1caea70..d6bb405 100644 --- a/biome.json +++ b/biome.json @@ -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, diff --git a/docs/opencode-v2-poc.md b/docs/opencode-v2-poc.md new file mode 100644 index 0000000..d918adf --- /dev/null +++ b/docs/opencode-v2-poc.md @@ -0,0 +1,73 @@ +# Experimental OpenCode v2 POC + +This branch is a **proof of concept**, not a supported vimcode release. The same `./tui` entry has v1 `tui(api, options)` and v2 `setup(context)` callbacks. v1 still uses its existing API; v2 adapts the host UI, storage, commands and events to the same Vim controller and engine. + +## Local development + +Run `just dev2` from this worktree. It uses npm's package runner (`npx`) to download/cache the tested `@opencode/cli@2.0.15` release, then launches a standalone v2 instance with the local plugin. It does not replace your installed `opencode` or change `just dev`. + +Settings, credentials, cache and history are stored separately under the ignored `.dev2/` directory. Existing provider environment variables and project config still apply; otherwise connect a provider inside the dev instance. The launcher clears inherited OpenCode config overrides and fixes the dev leader to `ctrl+x`. + +To use an existing v2 binary instead: + +```sh +just dev2 /absolute/path/to/opencode-v2 +# Or set OPENCODE_V2_BIN for repeated runs. +``` + +The root `tui.ts` re-exports `src/index.ts` for v2's local-directory loader. This shim and the dev launcher are not shipped in the package. Local development does not replace the installed-package checks below. + +## Package configuration + +The v2 public `keymap` has no key intercept or configured-leader lookup. The POC uses `context.renderer.keyInput.prependListener("keypress", ...)` to intercept prompt keys **before** the host keymap. It calls both `preventDefault()` and `stopPropagation()` on consumed keys. This raw OpenTUI ordering is not a documented OpenCode plugin guarantee; do not rely on it as production compatibility without testing against installed packages and future host releases. + +Build a tarball from this worktree with `npm pack --ignore-scripts --pack-destination /absolute/path/to/artifacts`. With a separate v2 installation, configure that artifact in the global `cli.json` (not v1's `tui.json`): + +```json +{ + "plugins": [ + { + "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz", + "options": { "updateCheck": false, "experimentalV2Leader": "ctrl+x" } + } + ] +} +``` + +Use a new artifact directory when repacking so the host does not reuse an older cache entry. The plugin's `package.json` already exports `./tui`; there is no v2-only package or runtime dependency. For local source, `just dev2` uses the root `tui.ts` shim required by v2's directory discovery. Use the tarball path for testing distributed-package behavior. + +`experimentalV2Leader` defaults to `"ctrl+x"`, accepts a string or an array of strings, and can be set to `false` or `"none"` if the host leader is disabled. If the host leader is `space`, set **both** the host keybind and `experimentalV2Leader: "space"`. The v2 plugin cannot detect the host leader setting, so mismatches can swallow shortcuts or swallow printable input. The v2 disabled state/update-check timestamp live in the new plugin-scoped `settings` store and do not migrate v1 KV values. + +## POC results (2026-09-30) + +The same tarball was exercised in real macOS tmux sessions on OpenCode **2.0.15** and **1.18.33**, using isolated HOME/XDG directories and synthetic prompt text. No model requests were sent. Test clipboard writes were redirected to a temporary file. + +- The initial screen-text smoke suite reported 13 passes per host for core editing, autocomplete and palette integration. Follow-up exact-buffer tests found that the visual-delete assertion was too weak: the expected suffix also matched a buffer with an extra character. Visual selection differs between the hosts; the first smoke suite does not establish parity. See [the follow-up investigation](opencode-v2-strategy.md). +- v2 loaded through its package cache on the first launch. Stock vimcode v0.18.1 was rejected by the same host with `Invalid V2 TUI plugin module`. +- Initial v1 first-launch failures used a symlink-spelled XDG cache (`/tmp` on macOS). With only `XDG_CACHE_HOME` changed to canonical `/private/tmp`, a fresh v1.18.33 tarball and the README Git spec loaded on the first launch. The `./tui`-only manifest is retained; do not add a root export, which redirects v2 plugin installation to the server target. See [the installer findings](opencode-v2-strategy.md#5-cold-installation-and-canonical-cache-paths). +- With both v2 leader settings set to `space`, spaces inserted normally, and `p` opened the host palette using `"command.palette.show": ["ctrl+p", "p"]`. +- v2 `/vim` disable persisted across restart. Re-enabling restored consumption. Removing/re-adding the plugin through live config removed the listener and restored a single `x` deletion, with no duplicate deletion observed. +- The maintainer also reported successful manual smoke checks through both `just dev` and `just dev2`. +- A repeatable installed-package exact-state harness now verifies visual selection both directions, physical-key setup, Unicode replacement, custom leader, autocomplete, v2 forms/permission rejection, disable/reload, and full tarball/cache identity. After review fixes, independent parent receipts on v1.18.33/v2.0.15 report 26/33 passing checks respectively, two explicitly named inherited known gaps each, zero failures; cold and warm loads both passed. Visual checkpoints assert offsets, ranges, selected text and focus, including mixed horizontal/word/line/vertical motions. Each checkpoint requires a fresh observer acknowledgement. Typecheck still reports the same eight pre-existing errors. + +Latest parent receipts are `/private/tmp/vimcode-v2-hardening/review-fixed-v1/receipt.json` and `review-fixed-v2/receipt.json`. Both verify artifact SHA-256 `8e43ee3812ecf2baf325fcc97b4e73d6e49ea116ed719fb2e7e12dbab218f7f0`. Earlier exploratory evidence remains under `/tmp/vimcode-v2-poc-runtime/` and `/tmp/vimcode-v2-alternatives/`. + +## Before claiming support + +Follow-up live tests covered root/child forms and permission rejection. They reproduced a host space-leader bug inside textual forms, including without vimcode installed. The v2 adapter now protects printable leader characters in focused forms, and the tested multiword root/child answers submitted correctly. The guard does not interpret other form keys as Vim commands and stays inactive when vimcode is disabled. + +Earlier exact-buffer tests on v1.15.4, v1.18.33 and v2.0.15 found shared tab/CJK/combining-character deletion bugs and missing redo after snapshot undo. The v1/v2 character-wise visual `h`/`l` difference was subsequently normalized and exact-state checked on v1.18.33/v2.0.15, including backwards motions. Tab offset and snapshot redo remain explicit `known-gap` probes; the harness does not silently count them as passing. Other native selection motions, broader grapheme handling, non-macOS terminals and other releases still need validation. The shared controller retains its existing deferred-yank teardown limitation. See [the strategy and evidence](opencode-v2-strategy.md). + +## Repeatable host check + +The optional runner needs Python 3.9+, npm, tmux and an explicit OpenCode binary. Use a **new empty output directory outside any checkout**, preferably a canonical OS-temp path (for example `/private/tmp` on macOS, not its `/tmp` symlink spelling). It refuses project OpenCode config ancestors, pins the binary version and runs the host with `env -i`, an isolated HOME/XDG set, a dedicated tmux socket, synthetic sessions, and clipboard stubs. It does not contact a model, approve a permission or read the system clipboard. It requires macOS, Linux or WSL; native Windows has not been tested. + +```sh +just compat v1 /absolute/path/to/opencode-1.18.33 1.18.33 /private/tmp/vimcode-v1-fresh-run +just compat v2 /absolute/path/to/opencode-2.0.15 2.0.15 /private/tmp/vimcode-v2-fresh-run +just compat-unit +``` + +Each invocation packs the current worktree, tests cold and warm plugin loading independently, and checks the installed source/manifest byte-for-byte against the tarball. The output `receipt.json` records environment, artifact/version, exact editor text/cursor/selection traces and each check as `pass`, `known-gap` or `fail`. A nonce handshake prevents stale observer output from counting as a checkpoint. A cold failure makes the command fail even if the warm replay succeeds. Cleanup timeouts are recorded as failures without losing the final receipt; only the run's private tmux socket/session is targeted. The test-only fixture lives in `test/compat/fixture` and is excluded from the published package (`package.json` ships only `src/`). This is a manual host check, not part of fast `just check` CI. + +The POC demonstrates dual-host core editing, not complete v2 feature parity or a production-safe input API. diff --git a/docs/opencode-v2-strategy.md b/docs/opencode-v2-strategy.md new file mode 100644 index 0000000..712cc22 --- /dev/null +++ b/docs/opencode-v2-strategy.md @@ -0,0 +1,118 @@ +# OpenCode v2 implementation decision + +Investigation date: 2026-09-30. This is a POC assessment, not a supported-version declaration. + +## Recommendation + +Keep one package and one Vim engine. Use a small v2 renderer-input adapter for an experimental release while asking upstream to expose supported interception and leader matching. Do not replace the engine with an enumerated public-keymap implementation, and do not split v1/v2 release lines yet. + +The renderer route is the only complete key-capture route demonstrated here. Public layers work for named keys but fail arbitrary character handling. An upstream API would remove the input-hook workaround, but would not resolve our existing editor offset, selection and undo issues. + +## Experiments + +All runtime tests used macOS ARM64, isolated HOME/XDG directories, synthetic text and separate tmux sessions. No model prompts were sent. Clipboard writes were redirected to a test file; system clipboard paste was not exercised. The real v1/v2 binaries and installed tarballs were tested, not just mocked APIs. + +### 1. Dual entrypoint and renderer interception + +One default `{ id, tui, setup }` export loaded on v1.15.4, v1.18.33 and v2.0.15. The pure `src/vim/` engine remained unchanged. The current v2 facade is 239 lines, including structural types, storage/events/UI adaptation and input routing. That is the total v2 adapter size, not a measurement of the incremental cost of keeping v1. + +Working core cases included ASCII motions/counts, text objects, replacement with an emoji, unknown-character consumption, multiline undo, palette/autocomplete routing and matching space-leader shortcuts. v2 disable persisted across restart; unloading removed interception and reloading did not duplicate an `x` deletion. + +v2.0.20 source retains the relevant input/context APIs, but its binary was not tested. The configured registry had 2.0.15; direct npm access failed and an Unpkg binary download returned HTTP 500. Do not infer a v2.0.20 runtime pass from source similarity. + +### 2. Public-keymap-only prototype + +A separate copy implemented normal/visual and insert input using public `context.keymap.layer`, without a renderer listener or private host import on that route. It registered 74 normal/visual bindings and 6 insert bindings for the tested space-leader configuration. + +| Case | Actual result | +| --- | --- | +| `ggwdw` and ASCII `rX` | Worked | +| Bound normal-mode `z` | Consumed | +| Unbound normal/visual `é` or emoji | Inserted into the prompt | +| `r` followed by `é` | Inserted `éalpha...`, instead of replacing to `élpha...`; operator remained pending | +| `r` followed by emoji | Same insertion-versus-replacement failure | +| Matching space leader then `p` | Opened host palette | +| Space in insert mode | Worked with an explicit leader mirror | + +OpenTUI 0.5.10 parses `*` as a literal key. Named sequence patterns require registration, which the public v2 plugin context does not expose. An invented `{any}` binding is not a catch-all. `shortcuts("leader")` returned `[]`; `pending()` exposed the leader only after it was pressed. Parsing display strings from another command's shortcuts is not a reliable replacement for leader metadata. + +Compared with the first renderer POC, this prototype added 81 net lines to the facade and 8 to the controller, plus a test. The shared Vim engine still needed no changes. The measured code counts include the opt-in experimental route beside the raw route; they are not a standalone production implementation estimate. + +Conclusion: public layers are viable for a reduced, explicitly bounded mode, not full vimcode behavior. Enumerating more characters does not provide arbitrary grapheme capture. The prototype's passing characterization tests include expected failures of parity; they are not a compatibility pass. + +### 3. Live forms and permissions + +A temporary TUI fixture used the public client to create a session, form and permission request without executing a tool or calling a model. An empty session export/import established a child relationship for testing child-owned prompts while the root session remained open. + +- With the default ctrl+x leader, a textual form accepted `hello world`, submitted that exact answer, and returned control to Vim. +- With a confirmed active space leader, the initial POC lost the space inside a form. Following letters could invoke host leader shortcuts and leave the form. +- Removing vimcode reproduced the problem: typing `hello ` left `hello` in the form and `pending()` contained `{ key: "space", token: "leader" }`. This is host behavior, not a bug introduced by the POC. +- A narrow guard now inserts printable leader characters in focused form editors without invoking the Vim engine. The guarded cache-installed variant submitted exact root and child answers (`hello world`, `child words`). +- Root permission rejection and a child permission rejection with the multiword message `no thanks` worked. Pending requests cleared, and normal key consumption resumed. No permission was approved. + +The guard was integrated into `src/v2.ts`, with tests for ordinary form-key passthrough, focus and disabled-state behavior. Other forms, arbitrary nesting, simultaneous requests, and non-text controls still need coverage before claiming parity. + +### 4. Exact editor-state checks + +The initial screen-text smoke test was not sufficient to establish editing parity. In particular, its substring assertion for `ha beta gamma` also accepted `pha beta gamma`. A temporary observer now captures the exact synthetic buffer, cursor, selection, focused editor and host mode; it does not modify product source or consume keyboard events. + +| Exact-state case | v1.15.4 | v1.18.33 | v2.0.15 | +| --- | --- | --- | --- | +| ASCII `dw`, `diw`/`ciw` and undo | Worked | Worked | Worked | +| Replace first character with emoji | Worked | Worked | Worked | +| Swallow unknown non-ASCII normal keys | Worked | Worked | Worked | +| Redo after snapshot-backed undo | No redo | No redo | No redo | +| End-of-word after tab | Wrong display offset | Same | Same | +| Delete `beta` from `a\tbeta gamma` | Corrupted range | Same | Same | +| Delete word after CJK/combining prefix | Corrupted range | Same | Same | +| Before normalization: `vllx` | Deleted 2 chars | Deleted 2 chars | Deleted 3 chars | +| After normalization: physical `vllx`, seeded forward/backward/cross-anchor | Not retested | Exact text and selection pass | Exact text and selection pass | + +The first group of defects exists in the shared controller/editor boundary, including on the unchanged v1 callback. Dropping v1 does not fix it. Selection behavior was a genuine host difference: v1's native `input.select.right` excluded the character at the cursor; v2 included it. The compatibility boundary now restores an inclusive range after successful deferred visual `h`/`l` motion, only when the original editor, visual anchor and mode still match. It updates `editorView.setSelection` without clearing the renderer's native anchor. Review caught a regression in an earlier version that called the top-level `setSelection`: `vl$x` left the first character behind. Before/after controls confirmed it, and mixed horizontal/word/line/vertical checks now verify that the original anchor survives. Pure engine actions remain unchanged. It does not establish parity for all other native selection motions or fix tab/wide-character offsets. The original PR #58 checkout contains separate cursor work and was not changed or merged into this POC. + +### 5. Cold installation and canonical cache paths + +The early v1 cold-install failures used a symlink-spelled cache path (`/tmp` on macOS, which resolves to `/private/tmp`). A controlled v1.18.33 experiment changed **only** `XDG_CACHE_HOME` from `/tmp/...` to canonical `/private/tmp/...` with an otherwise identical empty cache and package: the former extracted vimcode but omitted `:vim` on first launch, while the latter loaded `:vim` cold. A fresh install of the actual README Git tag also succeeded with the canonical cache path. The harness resolves `--output` before constructing XDG paths; its independent v1.18.33 and v2.0.15 runs both passed cold and warm installation of the same `./tui`-only tarball. + +This is a symlink-cache installer edge case, **not** evidence that all v1 cold Git or tarball installs fail, or that `exports["."]` is required. The v1 Arborist lockfile for `/tmp` used a malformed relative path whereas `/private/tmp` used `node_modules/vimcode`; warm startup bypassed the failed cold resolution. The bundled Arborist in-memory tree was not captured. Adding a generic root export did work around the symlinked v1 case, but real v2 `plugin add` then chose the **server** target and wrote `opencode.json` rather than CLI-only `cli.json`. Keep the package `./tui`-only. An upstream v1 installer fix should canonicalize the install root and accept a materialized package directory when an edge is absent. The isolated install controls are in `/tmp/vimcode-v2-hardening/install/`; the corrected upstream report is preserved as an [unposted draft](opencode-v2-upstream-drafts.md#v1-cold-installation-with-symlink-spelled-xdg-cache). + +## Route trade-offs and incremental v1 cost + +These cost assessments are engineering judgments, not measured time estimates. Only the prototype sizes and observed behavior above are measured. + +| Route | Main implementation/maintenance cost | Extra cost of retaining v1 | +| --- | --- | --- | +| Renderer input adapter | Complete capture works now; own ordering, focus, mode, lifecycle and custom-leader compatibility; re-test host updates | Small adapter/entrypoint code cost, moderate test/support cost. Keep the existing v1 registration path and share engine/editor fixes. Add v1 installed-artifact tests and separate config documentation. | +| Public layers only | More input-registration machinery but incomplete arbitrary-key behavior; still needs leader mirroring | Existing v1 interceptor can stay, but two different input dispatch designs must be tested. No engine fork is necessary. Retaining v1 is not what makes this route unsuitable. | +| Upstream-supported interceptor/leader API | Best input contract; requires upstream review/release and a minimum v2 version, or an interim fallback | Lowest long-term input-adapter divergence: v1 already exposes interception. Still need editor conformance tests and the v1 installer solution. | +| Private OpenCode imports | Bind directly to host internals/Solid context and runtime module resolution; no public compatibility guarantee | Does not simplify v1. More host-version coupling than the renderer route; not prototyped or recommended. | +| Separate v1/v2 packages or branches | Cleaner dependency/type separation, but duplicate releases, docs, update checking, triage and backports | Highest recurring v1 release overhead. It does not solve v2's input or editor problems. | + +Supporting v1 is not a second Vim implementation. New pure motions/text objects can remain shared. Most added work is verification: test v1 and v2 dispatch, overlays, native selection semantics, package installation and lifecycle. Existing shared bugs should be charged to core correctness, not to v1 compatibility. + +Keep the version promise bounded. A reasonable initial support target is the latest v1.18.x release plus a specifically validated v2 release. v1.15.4 is a useful proven-loading regression probe, but these experiments do not justify promising every v1 release or full parity at that floor. Testing both a minimum and latest v1 version costs more than supporting latest v1 only; neither implies maintaining a second engine. + +## Next implementation steps + +1. Keep the renderer route experimental, with the tested form guard and explicit leader mirror. +2. Extract only the small vimcode-owned host/editor contract actually needed. Avoid a general compatibility framework or separate engines. The v1-shaped facade was useful for the POC; keep or replace it based on whether it stays narrow. +3. Continue fixing shared tab/wide-character offset conversion and define undo/redo guarantees. Use the new exact-state harness on supported host versions; visual character selection was normalized on v1.18.33/v2.0.15, but other motions remain to be checked. +4. Keep the `./tui`-only manifest, validate cold installs with a canonical cache path, and report the symlink-cache edge case upstream rather than adding a root export. +5. Ask upstream for `context.keymap.intercept("key", handler, { priority })` with owned cleanup, and `context.keymap.isLeader(event)` or equivalent trigger metadata. These capabilities already exist in the internal keymap. Also report the textual-form leader issue and subpath-only installer behavior separately. +6. If upstream supplies the API, switch that one adapter boundary. Decide later whether older v2 releases retain a raw fallback or require upgrading. Neither choice requires dropping v1. + +The upstream report drafts remain unposted. This investigation is not a release or a declaration of complete host parity. + +## Evidence and sources + +Local experiment artifacts are temporary; the decision and findings are retained here. + +- Renderer exact-state driver: `/tmp/vimcode-v2-alternatives/extended-smoke.py`; receipts under `renderer/artifacts/`, `v115/artifacts/`, `v118/artifacts/` in that directory. Ignore its exploratory `ctrl-o-normal-pass` text assertion: v2 correctly opened a host modal, so that assertion was not a valid editing invariant. +- Root/child form fixture: `renderer/form-fixture/tui.ts`; exact answered states in `guarded-form-space-result.json` and `child-form-space-result.json`; no-plugin pending-leader evidence in `no-plugin-form-space-pending.json`. +- Public-layer prototype and receipts: `/tmp/vimcode-v2-alternatives/layers/.lab/`; final artifact `build-009/vimcode-0.18.1.tgz`, SHA-256 `15a24d74250974f4bb97bbe2f9deac371dfe6826fa6d8e76ef83364cd5202843`. +- Guarded renderer artifact: `/tmp/vimcode-v2-alternatives/formguard/artifact/`; root-export experiment: `/tmp/vimcode-v2-alternatives/root-export/`. +- Durable optional harness: `just compat v1 /absolute/opencode 1.18.33 /canonical/empty/output` or `just compat v2 /absolute/opencode 2.0.15 /canonical/empty/output`; see [POC instructions](opencode-v2-poc.md#repeatable-host-check). Its cold/warm receipts verify the complete installed source and manifest, exact synthetic editor state, physical-key visual setup, root/child forms, permission rejection and reload. Latest independent parent receipts: `/private/tmp/vimcode-v2-hardening/review-fixed-v1/receipt.json` (26 pass, 2 named known gaps) and `/private/tmp/vimcode-v2-hardening/review-fixed-v2/receipt.json` (33 pass, 2 named known gaps), both cold/warm pass and all-source identity verified. Visual checkpoints assert cursor, range, selected text and focused ownership, and require a fresh observer nonce. Eleven Python tests cover comparison failures, stale observations and durable failure receipts on cleanup timeouts. `just check` passed in parent validation; the eight pre-existing type errors remain outside this POC's scope. +- [v2 public plugin context](https://github.com/anomalyco/opencode/blob/v2.0.20/packages/plugin/src/tui/context.ts), [internal keymap](https://github.com/anomalyco/opencode/blob/v2.0.20/packages/tui/src/context/keymap.tsx), [public context construction](https://github.com/anomalyco/opencode/blob/v2.0.20/packages/tui/src/plugin/api.tsx). +- [OpenTUI keymap contract](https://opentui.com/docs/keymap/core), [0.5.10 key-event dispatch](https://github.com/anomalyco/opentui/blob/v0.5.10/packages/core/src/lib/KeyHandler.ts). +- [v1 installer](https://github.com/anomalyco/opencode/blob/v1.18.33/packages/core/src/npm.ts), [v1 plugin resolution](https://github.com/anomalyco/opencode/blob/v1.18.33/packages/opencode/src/plugin/shared.ts). +- [v2 entrypoint resolution](https://github.com/anomalyco/opencode/blob/v2.0.15/packages/plugin/src/host.ts), [v2 plugin-add target selection](https://github.com/anomalyco/opencode/blob/v2.0.15/packages/cli/src/commands/handlers/plugin/add.ts). diff --git a/docs/opencode-v2-upstream-drafts.md b/docs/opencode-v2-upstream-drafts.md new file mode 100644 index 0000000..cc29ee9 --- /dev/null +++ b/docs/opencode-v2-upstream-drafts.md @@ -0,0 +1,19 @@ +# Upstream reports to consider + +These are **drafts only**. Nothing was filed or posted. Runtime observations use OpenCode 2.0.15 on macOS ARM64; v2.0.20 references are source inspection, not a tested binary. + +## Public CLI key interception and leader matching + +The v2 CLI plugin context exposes named keymap layers, but no pre-dispatch catch-all or configured-leader matcher. A vimcode prototype with explicit public-layer bindings handled ASCII motions; unbound `é` and emoji leaked into normal/visual input and inserted after `r` instead of replacing. `*` is a literal binding; public plugins cannot register general key sequence patterns. `shortcuts("leader")` returned `[]`; `pending()` identified the leader only after it fired. + +A raw `context.renderer.keyInput.prependListener("keypress", ...)` works but depends on OpenTUI listener ordering. Request supported plugin-context equivalents of the internal `Keymap.use().intercept("key", handler, { priority? })` with consumption and plugin-owned disposal, plus `isLeader(event)` or readable configured leader metadata. Acceptance tests should prove consumed printable keys never reach the composer, unconsumed keys do, a non-default space leader matches, and unloading removes the interception. This asks for a generic input extension, not built-in Vim behavior. Relevant source: [public context](https://github.com/anomalyco/opencode/blob/v2.0.20/packages/plugin/src/tui/context.ts), [plugin API adapter](https://github.com/anomalyco/opencode/blob/v2.0.20/packages/tui/src/plugin/api.tsx), [internal keymap](https://github.com/anomalyco/opencode/blob/v2.0.20/packages/tui/src/context/keymap.tsx). + +## Printable leader inside textual forms + +With `keybinds.leader: "space"` in v2.0.15's `cli.json`, restart the TUI and create an idle synthetic session and string form using the public client. In a focused form textarea, typing `hello ` leaves exact text `hello` while `keymap.pending()` shows `{ key: "space", token: "leader" }`; subsequent letters can trigger shortcuts. This reproduces **without vimcode**. Expected: focused text-form input receives printable leader characters without losing intentional shortcuts for non-text controls. Ctrl+x accepted `hello world`. Vimcode's narrow renderer guard inserted the space in tested root and child forms, but does not fix the host. Source: [v2 form](https://github.com/anomalyco/opencode/blob/v2.0.15/packages/tui/src/routes/session/form.tsx), [host keymap](https://github.com/anomalyco/opencode/blob/v2.0.15/packages/tui/src/context/keymap.tsx). + +## v1 cold installation with symlink-spelled XDG cache + +On v1.18.33 with isolated HOME/XDG, a `./tui`-only plugin configured by the actual vimcode Git tag or a matching tarball is extracted but lacks `:vim` on first launch when `XDG_CACHE_HOME` is `/tmp/...` on macOS (`/tmp` resolves to `/private/tmp`). Restarting loads it. Change **only** the cache path spelling to `/private/tmp/...` with a fresh empty cache: both Git and tarball load on the first launch. The former lockfile contains a malformed `../../.../private/tmp/.../node_modules/vimcode` key, whereas the canonical case uses `node_modules/vimcode`; standalone Arborist 9.1.6 showed empty versus populated installed edges. The v1 host bundles Arborist 9.4.0; its in-memory tree was not captured. This is not a general fresh-install failure. Request canonicalizing the installation root before Arborist reify and accepting an actually materialized `node_modules/` TUI package when no edge is returned. A root-export stub is not safe: real v2 `plugin add` then selects server and writes `opencode.json` rather than CLI-only `cli.json`. Source: [v1 installer](https://github.com/anomalyco/opencode/blob/v1.18.33/packages/core/src/npm.ts#L88-L145), [v2 installer](https://github.com/anomalyco/opencode/blob/v2.0.15/packages/util/src/npm.ts#L262-L282). + +Retain the `./tui`-only package manifest. These reports can be reviewed with their isolated reproduction receipts before posting; see [the POC strategy](opencode-v2-strategy.md) for compatibility limits. diff --git a/justfile b/justfile index 793b4f4..5f61ab6 100644 --- a/justfile +++ b/justfile @@ -21,10 +21,22 @@ check: just lint just test +# Optional real-host check: use an empty output dir outside any checkout. +compat host binary version output: + python3 test/compat/run.py --host "{{host}}" --binary "{{binary}}" --expect-version "{{version}}" --output "{{output}}" + +# Pure comparison checks for the optional real-host harness. +compat-unit: + python3 -B -m unittest discover -s test/compat -p 'test_*.py' + # Launch OpenCode with the vimcode plugin active. # Unset OPENCODE_CONFIG_DIR so dev-tui.json keybinds aren't # overridden by the global dotfiles config (which is level 4). dev: OPENCODE_TUI_CONFIG=dev-tui.json OPENCODE_CONFIG_DIR= opencode +# Launch local source in an isolated v2 instance; optionally supply a v2 binary. +dev2 binary="": + bun run scripts/dev2.ts "{{binary}}" + diff --git a/scripts/dev2.ts b/scripts/dev2.ts new file mode 100644 index 0000000..9a777dd --- /dev/null +++ b/scripts/dev2.ts @@ -0,0 +1,51 @@ +import { mkdirSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = realpathSync(fileURLToPath(new URL("../", import.meta.url))); +const state = join(root, ".dev2"); +mkdirSync(state, { recursive: true }); +const stateRoot = realpathSync(state); +const binary = process.argv[2] || process.env.OPENCODE_V2_BIN; +const npx = process.platform === "win32" ? [process.env.ComSpec ?? "cmd.exe", "/d", "/c", "npx"] : ["npx"]; +const command = binary ? [binary] : [...npx, "--yes", "--package=@opencode/cli@2.0.15", "opencode"]; + +// Keep v2's config migration, credentials and history away from the v1 setup. +const env = { ...process.env }; +for (const key of ["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_TUI_CONFIG"]) { + delete env[key]; +} +Object.assign(env, { + XDG_CONFIG_HOME: join(stateRoot, "config"), + XDG_DATA_HOME: join(stateRoot, "data"), + XDG_CACHE_HOME: join(stateRoot, "cache"), + XDG_STATE_HOME: join(stateRoot, "state"), + OPENCODE_CLI_CONFIG_CONTENT: JSON.stringify({ + plugins: [{ package: root, options: { updateCheck: false, experimentalV2Leader: "ctrl+x" } }], + keybinds: { leader: "ctrl+x" }, + }), +}); + +let version: string; +try { + const probe = Bun.spawnSync([...command, "--version"], { cwd: root, env, stdout: "pipe", stderr: "inherit" }); + if (probe.exitCode !== 0) process.exit(probe.exitCode); + version = new TextDecoder().decode(probe.stdout).trim(); +} catch (error) { + process.stderr.write(`Could not launch OpenCode v2: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +} +if (!/^(?:opencode v)?2\./.test(version)) { + process.stderr.write(`dev2 requires OpenCode 2.x, got ${JSON.stringify(version)}. Use just dev for v1.\n`); + process.exit(1); +} + +process.stdout.write(`Starting ${version} with local vimcode; separate state: ${stateRoot}\n`); +const child = Bun.spawn([...command, "--standalone", root], { + cwd: root, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}); +process.exit(await child.exited); diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..d4ea01d --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,20 @@ +import type { RGBA } from "@opentui/core"; + +type VisualSelectionEditor = { + cursorOffset: number; + selectionBg?: RGBA; + selectionFg?: RGBA; + editorView?: { setSelection(start: number, end: number, bg?: RGBA, fg?: RGBA): void }; + requestRender?: () => void; +}; + +// Normalize the visible range without clearing the renderer's native anchor. +// The top-level setSelection() clears it, so a following word/line motion +// would restart selection at the current cursor instead of the Vim anchor. +export function selectVisualCharacterRange(editor: VisualSelectionEditor, anchor: number): void { + if (!editor.editorView?.setSelection) return; + const start = Math.min(anchor, editor.cursorOffset); + const end = Math.max(anchor, editor.cursorOffset) + 1; + editor.editorView.setSelection(start, end, editor.selectionBg, editor.selectionFg); + editor.requestRender?.(); +} diff --git a/src/index.ts b/src/index.ts index 1e56199..8212327 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ -import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; +import type { TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"; import { writeClipboard } from "./clipboard"; +import { selectVisualCharacterRange } from "./editor"; import { findMatchingLeader, type KeyLike, leaderChar } from "./leader"; +import { createV2Facade, type V2Context } from "./v2"; import { checkForUpdate } from "./version"; import { type Action, @@ -13,9 +15,9 @@ import { translateKey, } from "./vim"; -const plugin: TuiPluginModule = { +const plugin = { id: "vimcode", - tui: async (api, options) => { + tui: async (api: TuiPluginApi, options?: Record) => { const state = createVimState(); const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; @@ -132,9 +134,28 @@ const plugin: TuiPluginModule = { undoSnapshots = []; } switch (action.type) { - case "cmd": - setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0); + case "cmd": { + const visualAnchor = + state.mode === "visual" && (action.cmd === "input.select.left" || action.cmd === "input.select.right") + ? state.visualAnchor + : undefined; + const visualEditor = visualAnchor === undefined ? undefined : api.renderer?.currentFocusedEditor; + setTimeout(() => { + const dispatched = api.keymap.dispatchCommand(action.cmd); + if ( + dispatched?.ok && + visualEditor && + visualAnchor !== undefined && + state.mode === "visual" && + state.visualAnchor === visualAnchor && + api.renderer?.currentFocusedEditor === visualEditor && + api.renderer?.currentFocusedRenderable === visualEditor + ) { + selectVisualCharacterRange(visualEditor, visualAnchor); + } + }, 0); break; + } case "mode": if (modeIndicator === "toast") { const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); @@ -329,6 +350,11 @@ const plugin: TuiPluginModule = { } } + // The v2 facade exposes the host mode here. Autocomplete can also + // appear while vim is in normal mode; its own layer owns those keys. + const hostMode = (api.keymap as typeof api.keymap & { mode?: { current(): string } }).mode?.current(); + if (hostMode === "autocomplete" && state.mode !== "insert") return; + // Let autocomplete handle Enter/Escape before vim consumes them. // dispatchCommand returns { ok } — true when the autocomplete layer // is active and handled the command, false when it's hidden/disabled. @@ -403,7 +429,17 @@ const plugin: TuiPluginModule = { { priority: 10_000 }, ); }, -}; + async setup(context: V2Context) { + const { api, dispose } = createV2Facade(context); + try { + await plugin.tui(api, context.options); + return dispose; + } catch (error) { + dispose(); + throw error; + } + }, +} satisfies TuiPluginModule & { setup: (context: V2Context) => Promise<() => void> }; function offsetToLineCol(text: string, offset: number): [number, number] { const before = text.substring(0, offset); diff --git a/src/v2.ts b/src/v2.ts new file mode 100644 index 0000000..17c94da --- /dev/null +++ b/src/v2.ts @@ -0,0 +1,239 @@ +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { findMatchingLeader, leaderChar } from "./leader"; +import type { KeyEvent } from "./vim"; + +type V2EventName = "permission.asked" | "permission.replied" | "form.created" | "form.replied" | "form.cancelled"; +type V2Event = { data: { sessionID?: string; form?: { sessionID: string } } }; +type V2Settings = { disabled: boolean; lastUpdateCheck: string }; +type V2Command = { + id: string; + title: string; + description?: string; + group: string; + palette: true; + slash?: { name: string }; + run: () => void | Promise; +}; +type V1Command = { + name: string; + title: string; + desc?: string; + category: string; + slashName?: string; + run: () => void | Promise; +}; +type RawKeyEvent = KeyEvent & { preventDefault(): void; stopPropagation(): void }; + +// Only the v2 surface used here is modeled. This structural boundary avoids a +// runtime dependency on either host's plugin package in the shared ./tui entry. +export type V2Context = { + options: Readonly>; + location?: unknown; + renderer: { + currentFocusedEditor?: unknown; + currentFocusedRenderable?: unknown; + keyInput: { + prependListener(event: "keypress", handler: (key: RawKeyEvent) => void): void; + off(event: "keypress", handler: (key: RawKeyEvent) => void): void; + }; + }; + keymap: { + dispatch(id: string): void; + mode: { current(): string }; + layer(input: () => { mode: "global"; commands: V2Command[] }): void; + }; + storage: { + store( + key: string, + options: { initial: T }, + ): readonly [Readonly, (update: (draft: T) => void) => Promise]; + }; + data: { + on(event: V2EventName, handler: (event: V2Event) => void): () => void; + session: { + root(sessionID: string): string; + family(sessionID: string): string[]; + permission: { list(sessionID: string): unknown[] | undefined }; + form: { list(sessionID: string, location?: unknown): unknown[] | undefined }; + }; + }; + ui: { + toast: { + show(options: { message: string; variant?: "info" | "success" | "warning" | "error"; duration?: number }): void; + }; + router: { current(): { type: string; sessionID?: string } }; + slot(claim: { append: "app"; render: () => null }): () => void; + }; +}; + +export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose: () => void } { + const cleanups: Array<() => void> = []; + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + for (const cleanup of cleanups.reverse()) cleanup(); + }; + const own = (cleanup: () => void) => { + let active = true; + const once = () => { + if (!active) return; + active = false; + cleanup(); + }; + cleanups.push(once); + return once; + }; + + const [settings, updateSettings] = context.storage.store("settings", { + initial: { disabled: false, lastUpdateCheck: "" }, + }); + const config = context.options.experimentalV2Leader; + const leader = + config === undefined + ? ["ctrl+x"] + : config === false || config === "none" + ? [] + : Array.isArray(config) + ? config + : [config]; + const leaders = leader.filter((key): key is string => typeof key === "string" && key !== "none"); + const members = (sessionID: string) => [...new Set([sessionID, ...context.data.session.family(sessionID)])]; + const v2Events: Record = { + "permission.asked": "permission.asked", + "permission.replied": "permission.replied", + "question.asked": "form.created", + "question.replied": "form.replied", + "question.rejected": "form.cancelled", + }; + + const facade = { + renderer: context.renderer, + keymap: { + mode: context.keymap.mode, + dispatchCommand(command: string) { + if (disposed) return { ok: false }; + if (command.startsWith("prompt.autocomplete.") && context.keymap.mode.current() !== "autocomplete") { + return { ok: false }; + } + context.keymap.dispatch(command); + return { ok: true }; + }, + registerLayer(input: { commands: V1Command[] }) { + const commands: V2Command[] = input.commands.map((command) => ({ + id: command.name, + title: command.title, + description: command.desc, + group: command.category, + palette: true, + ...(command.slashName ? { slash: { name: command.slashName } } : {}), + run: command.run, + })); + // v2 layers require a Solid owner; the nonvisual app slot supplies one. + own( + context.ui.slot({ + append: "app", + render: () => { + context.keymap.layer(() => ({ mode: "global", commands })); + return null; + }, + }), + ); + }, + intercept(type: string, handler: (input: { event: RawKeyEvent; consume(): void }) => void) { + if (type !== "key") throw new Error(`Unsupported v2 key intercept: ${type}`); + // POC: v2's public keymap does not expose intercept(). OpenTUI's + // prependListener runs before the host keymap listener; both stop flags + // are needed to keep consumed keys out of the keymap and textarea. + const onKey = (event: RawKeyEvent) => { + if (disposed || event.eventType === "release") return; + const route = context.ui.router.current(); + const mode = context.keymap.mode.current(); + if (route.type !== "home" && route.type !== "session") return; + const editor = context.renderer.currentFocusedEditor as { insertText?(text: string): void } | undefined; + if (!editor || editor !== context.renderer.currentFocusedRenderable) return; + // v2's leader token also matches inside textual forms. Insert its + // character without running Vim commands against the form's input. + if (mode === "form" && !settings.disabled) { + const leader = findMatchingLeader(event, leaders); + const character = leader && leaderChar(leader); + if (character && editor.insertText) { + event.preventDefault(); + event.stopPropagation(); + editor.insertText(character); + } + return; + } + if (mode !== "base" && mode !== "autocomplete") return; + handler({ + event, + consume: () => { + event.preventDefault(); + event.stopPropagation(); + }, + }); + }; + context.renderer.keyInput.prependListener("keypress", onKey); + return own(() => context.renderer.keyInput.off("keypress", onKey)); + }, + }, + ui: { + toast: (input: { message: string; variant?: "info" | "success" | "warning" | "error"; duration?: number }) => { + if (!disposed) context.ui.toast.show(input); + }, + dialog: { + get open() { + const mode = context.keymap.mode.current(); + return mode !== "base" && mode !== "autocomplete"; + }, + }, + }, + route: { + get current() { + const route = context.ui.router.current(); + return { name: route.type, params: { sessionID: route.sessionID } }; + }, + }, + state: { + session: { + get: (sessionID: string) => ({ parentID: context.data.session.root(sessionID) }), + question: (sessionID: string) => + members(sessionID).flatMap((id) => context.data.session.form.list(id, context.location) ?? []), + permission: (sessionID: string) => + members(sessionID).flatMap((id) => context.data.session.permission.list(id) ?? []), + }, + }, + event: { + on(name: string, handler: (event: { properties: { sessionID?: string } }) => void) { + const mapped = v2Events[name]; + if (!mapped) throw new Error(`Unsupported v2 event: ${name}`); + return own( + context.data.on(mapped, (event) => { + if (!disposed) handler({ properties: { sessionID: event.data.form?.sessionID ?? event.data.sessionID } }); + }), + ); + }, + }, + tuiConfig: { keybinds: { get: (name: string) => (name === "leader" ? leaders.map((key) => ({ key })) : []) } }, + kv: { + get: async (key: string) => + key === "vimcode.disabled" + ? settings.disabled + : key === "lastUpdateCheck" + ? settings.lastUpdateCheck + : undefined, + set: async (key: string, value: unknown) => { + if (disposed) return; + await updateSettings((draft) => { + if (key === "vimcode.disabled") draft.disabled = value === true; + if (key === "lastUpdateCheck") draft.lastUpdateCheck = String(value); + }); + }, + }, + lifecycle: { onDispose: (fn: () => void) => own(fn) }, + }; + + // The v1 callback uses only the properties implemented above. The cast is + // confined here; it does not claim this is a complete v1 TuiPluginApi. + return { api: facade as unknown as TuiPluginApi, dispose }; +} diff --git a/test/compat/.gitignore b/test/compat/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/test/compat/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/test/compat/cases.py b/test/compat/cases.py new file mode 100644 index 0000000..58ebc64 --- /dev/null +++ b/test/compat/cases.py @@ -0,0 +1,184 @@ +"""Exact-buffer scenarios for the installed OpenCode TUI package.""" +from driver import atomic_json, capture, command, keys, normal, read_json, record, save, seed, send, snapshot, tmux, wait_for + + +def run_core(ctx): + # Physical typing first: no fixture cursor/selection reset can explain + # this selection trace. Later cases seed via top-level editor methods. + record(ctx, "fresh-editor", {"text": ""}) + send(ctx, "alpha beta gamma") + record(ctx, "physical-editor-input", {"text": "alpha beta gamma"}) + normal(ctx) + keys(ctx, "gg") + ctx["report"]["physicalVisualTrace"] = [snapshot(ctx), *keys(ctx, "vll")] + save(ctx) + record(ctx, "physical-visual-selection", {"offset": 2, "selected": "alp", "selection": {"start": 0, "end": 3}}) + send(ctx, "x") + record(ctx, "physical-visual-forward", {"text": "ha beta gamma", "offset": 0, "selected": "", "selection": None}) + seed(ctx, "alpha beta gamma") + keys(ctx, "wdw") + record(ctx, "ascii-word-delete", {"text": "alpha gamma", "offset": 6, "selected": "", "selection": None}) + send(ctx, "u") + record(ctx, "ascii-undo", {"text": "alpha beta gamma"}) + seed(ctx, "alpha beta gamma") + keys(ctx, "wdiw") + record(ctx, "text-object-delete", {"text": "alpha gamma"}) + seed(ctx, "alpha beta gamma") + keys(ctx, "🤖é") + record(ctx, "unbound-unicode-consumed", {"text": "alpha beta gamma"}) + keys(ctx, "r🤖") + record(ctx, "unicode-replace", {"text": "🤖lpha beta gamma"}) + seed(ctx, "alpha beta gamma") + ctx["report"]["visualForwardTrace"] = [snapshot(ctx), *keys(ctx, "vll")] + save(ctx) + record(ctx, "visual-forward-selection", {"offset": 2, "selected": "alp", "selection": {"start": 0, "end": 3}}) + send(ctx, "x") + record(ctx, "visual-forward-delete", {"text": "ha beta gamma", "offset": 0, "selected": "", "selection": None}) + seed(ctx, "alpha beta gamma", 4) + ctx["report"]["visualBackwardTrace"] = [snapshot(ctx), *keys(ctx, "vhh")] + save(ctx) + record(ctx, "visual-backward-selection", {"offset": 2, "selected": "pha", "selection": {"start": 2, "end": 5}}) + send(ctx, "x") + record(ctx, "visual-backward-delete", {"text": "al beta gamma", "offset": 2, "selected": "", "selection": None}) + seed(ctx, "alpha beta gamma", 2) + keys(ctx, "vhhlll") + record(ctx, "visual-crosses-anchor", {"offset": 3, "selected": "ph", "selection": {"start": 2, "end": 4}}) + send(ctx, "x") + record(ctx, "visual-cross-anchor-delete", {"text": "ala beta gamma", "offset": 2, "selected": "", "selection": None}) + + # Native word/vertical endpoint semantics still differ between hosts. The + # horizontal fix must preserve their original anchor, not change them. + v2 = ctx["args"].host == "v2" + for name, motions, value, end, offset in ( + ("word", "vlw", "alpha beta gamma", 7 if v2 else 6, 6), + ("line", "vl$", "alpha beta gamma", 16, 16), + ("cross-word", "vlbw", "alpha beta gamma", 7 if v2 else 6, 6), + ("vertical", "vlj", "alpha\nbravo\ncharlie", 8 if v2 else 7, 7), + ): + seed(ctx, value) + keys(ctx, motions) + record(ctx, f"mixed-{name}-selection", {"text": value, "offset": offset, + "selected": value[:end], "selection": {"start": 0, "end": end}}) + send(ctx, "x") + record(ctx, f"mixed-{name}-delete", {"text": value[end:], "offset": 0, + "selected": "", "selection": None}) + seed(ctx, "alpha beta gamma") + send(ctx, "i") + send(ctx, "a") + send(ctx, "space", False) + send(ctx, "b") + record(ctx, "insert-space-leader", {"text": "a balpha beta gamma"}) + normal(ctx) + send(ctx, "space", False) + send(ctx, "p") + pane = wait_for(lambda: capture(ctx), lambda value: "Commands" in value, "space leader palette", 5) + (ctx["output"] / "leader-palette.txt").write_text(pane) + send(ctx, "Escape", False) + record(ctx, "normal-leader-does-not-edit", {"text": "a balpha beta gamma"}) + seed(ctx, "") + send(ctx, "i") + send(ctx, "/") + wait_for(lambda: snapshot(ctx), lambda value: value.get("mode") == "autocomplete", "host autocomplete", 5) + send(ctx, "Escape", False) + dismissed = wait_for(lambda: snapshot(ctx), lambda value: value.get("mode") in ("base", "unknown"), + "autocomplete dismissal", 5) + ctx["report"]["autocompleteDismissedPrefix"] = dismissed["text"] + send(ctx, "x") + intended = "x" if ctx["args"].host == "v1" else "/x" + record(ctx, "autocomplete-escape-insert", {"text": intended}) + seed(ctx, "alpha beta gamma") + keys(ctx, "wdw") + send(ctx, "u") + send(ctx, "C-r", False) + record(ctx, "snapshot-redo", {"text": "alpha gamma"}, {"text": "alpha beta gamma"}) + seed(ctx, "a\tbeta gamma", 3) + keys(ctx, "diw") + record(ctx, "tab-inner-word-offset", {"text": "a\t gamma"}, {"text": "ata gamma"}) + + +def run_v2_prompts(ctx): + command(ctx, "session") + wait_for(lambda: snapshot(ctx), lambda value: value["route"].get("type") == "session", "synthetic session") + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "root form") + for ch in "hello world": + send(ctx, ch) + record(ctx, "root-form-space", {"text": "hello world", "mode": "form"}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + result = command(ctx, "formState") + if result["state"] != {"status": "answered", "answer": {"answer": "hello world"}}: + raise AssertionError(result) + command(ctx, "childForm") + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "child form") + for ch in "child words": + send(ctx, ch) + record(ctx, "child-form-space", {"text": "child words", "mode": "form"}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + result = command(ctx, "formState") + if result["state"] != {"status": "answered", "answer": {"answer": "child words"}}: + raise AssertionError(result) + command(ctx, "permission") + wait_for(lambda: capture(ctx), lambda pane: "Permission required" in pane, "pending permission") + send(ctx, "Escape", False) + wait_for(lambda: snapshot(ctx), lambda value: value["editorId"] == "session.permission.reject.message", + "child permission reject") + for ch in "no thanks": + send(ctx, ch) + record(ctx, "child-permission-message", {"text": "no thanks"}) + send(ctx, "Enter", False) + if command(ctx, "permissions") != []: + raise AssertionError("Pending permission was not rejected") + + +def run_v2_reload(ctx): + command(ctx, "home") + wait_for(lambda: snapshot(ctx), lambda value: value["route"].get("type") == "home", "home editor for reload", 5) + seed(ctx, "alpha beta gamma") + tmux(ctx, "send-keys", "-t", ctx["pane"], "C-p") + wait_for(lambda: capture(ctx), lambda value: "Commands" in value, "palette before Vim disable", 5) + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "vim") + wait_for(lambda: capture(ctx), lambda value: ":vim" in value, "Vim toggle command", 5) + send(ctx, "Enter", False) + send(ctx, "z") + record(ctx, "disabled-typing-passes-through", {"text": "zalpha beta gamma"}) + saved = list((ctx["output"] / "state" / "opencode").rglob("plugin.vimcode.settings.json")) + if len(saved) != 1 or read_json(saved[0]).get("disabled") is not True: + raise AssertionError(f"Disabled state did not persist: {saved}") + tmux(ctx, "send-keys", "-t", ctx["pane"], "C-p") + wait_for(lambda: capture(ctx), lambda value: "Commands" in value, "palette before Vim re-enable", 5) + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "vim") + wait_for(lambda: capture(ctx), lambda value: ":vim" in value, "Vim re-enable command", 5) + send(ctx, "Enter", False) + normal(ctx) + send(ctx, "z") + record(ctx, "re-enabled-unknown-consumed", {"text": "zalpha beta gamma"}) + if read_json(saved[0]).get("disabled") is not False: + raise AssertionError("Re-enabled state was not persisted") + + before_listeners = snapshot(ctx).get("keypressListeners") + # Reconciliation can reactivate the fixture itself; do not replay the last + # seed or synthetic session command against the new fixture activation. + ctx["request"].unlink(missing_ok=True) + ctx["command"].unlink(missing_ok=True) + config = read_json(ctx["config_file"]) + plugins = config["plugins"] + config["plugins"] = [item for item in plugins if not item["package"].startswith("vimcode@")] + atomic_json(ctx["config_file"], config) + wait_for(lambda: snapshot(ctx), lambda value: value.get("ready") is False, "vimcode unload", 8) + wait_for(lambda: snapshot(ctx), lambda value: value.get("keypressListeners", 0) < before_listeners, + "raw key listener disposed", 8) + ctx["report"]["unloadListenerCounts"] = {"before": before_listeners, + "after": snapshot(ctx)["keypressListeners"]} + send(ctx, "z") + record(ctx, "unloaded-typing-passes-through", {"text": "zzalpha beta gamma"}) + config["plugins"] = plugins + atomic_json(ctx["config_file"], config) + wait_for(lambda: snapshot(ctx), lambda value: value.get("ready") is True, "vimcode reload", 8) + wait_for(lambda: snapshot(ctx), lambda value: value.get("cursorStyle", {}).get("style") == "line", + "new vimcode instance starts in insert mode", 5) + normal(ctx) + seed(ctx, "alpha beta gamma") + send(ctx, "x") + record(ctx, "single-delete-after-reload", {"text": "lpha beta gamma"}) diff --git a/test/compat/driver.py b/test/compat/driver.py new file mode 100644 index 0000000..2a46480 --- /dev/null +++ b/test/compat/driver.py @@ -0,0 +1,289 @@ +"""Isolated tmux/fixture control for the optional real-host compatibility tests.""" +import hashlib +import json +import os +import platform +import shlex +import subprocess +import tarfile +import time +import uuid +from pathlib import Path + +FIXTURE = Path(__file__).resolve().parent / "fixture" + + +def atomic_json(path, data): + temp = path.with_suffix(path.suffix + ".next") + temp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + temp.replace(path) + + +def read_json(path): + return json.loads(path.read_text()) + + +def wait_for(read, check, label, seconds=25): + deadline = time.monotonic() + seconds + last = None + while time.monotonic() < deadline: + try: + last = read() + if check(last): + return last + except (OSError, ValueError): + pass + time.sleep(0.1) + raise RuntimeError(f"Timed out waiting for {label}; last state: {str(last)[:400]}") + + +def compare_state(name, actual, intended, prior=None): + def matches(expected): + return all(actual.get(key) == value for key, value in expected.items()) + status = "pass" if matches(intended) else "known-gap" if prior and matches(prior) else "fail" + return {"name": name, "status": status, "intended": intended, "prior": prior, "actual": actual} + + +def new_context(args, output, tarball): + socket = "vimcode-hardening-" + uuid.uuid4().hex[:10] + session = "vimcode-hardening-test" + return { + "args": args, + "output": output, + "tarball": tarball, + "socket": socket, + "session": session, + "pane": session + ":0.0", + "state": output / "state.json", + "request": output / "request.json", + "command": output / "command.json", + "result": output / "result.json", + "config": output / "config" / "opencode", + "report": { + "host": args.host, + "binary": str(args.binary), + "version": args.actual_version, + "platform": platform.platform(), + "environment": { + "isolation": "env -i with a dedicated tmux socket", + "home": str(output / "home"), + "xdgConfigHome": str(output / "config"), + "xdgDataHome": str(output / "data"), + "xdgCacheHome": str(output / "cache"), + "xdgStateHome": str(output / "state"), + "sandbox": str(output / "sandbox"), + "clipboard": "test-bin stubs for macOS, Linux and WSL; no native clipboard reads", + "terminal": "xterm-256color", + }, + "tmux": subprocess.check_output(["tmux", "-V"], text=True, timeout=8).strip(), + "artifact": str(tarball), + "artifactSha256": hashlib.sha256(tarball.read_bytes()).hexdigest(), + "output": str(output), + "coldInstall": {}, + "warmInstall": {}, + "checks": [], + }, + } + + +def tmux(ctx, *parts): + command = ["tmux", "-L", ctx["socket"], *parts] + return subprocess.run(command, text=True, capture_output=True, check=True, timeout=8).stdout + + +def capture(ctx): + return tmux(ctx, "capture-pane", "-p", "-t", ctx["pane"], "-S", "-35") + + +def snapshot(ctx): + return read_json(ctx["state"]) + + +def fresh_snapshot(ctx, seconds=3): + # A cached state file is not evidence that the observer is still alive. + token = uuid.uuid4().hex + atomic_json(ctx["request"], {"id": token, "action": "observe"}) + return wait_for(lambda: snapshot(ctx), lambda value: value.get("observed") == token, + "fresh observer acknowledgement", seconds) + + +def send(ctx, key, literal=True): + parts = ("send-keys", "-t", ctx["pane"], "-l", key) if literal else ("send-keys", "-t", ctx["pane"], key) + tmux(ctx, *parts) + time.sleep(0.18) + return fresh_snapshot(ctx) + + +def keys(ctx, sequence): + return [send(ctx, key) for key in sequence] + + +def save(ctx): + atomic_json(ctx["output"] / "receipt.json", ctx["report"]) + + +def record(ctx, name, intended, prior=None): + actual = fresh_snapshot(ctx) + intended = {"ownsFocus": True, **intended} + prior = {"ownsFocus": True, **prior} if prior else None + value = compare_state(name, actual, intended, prior) + ctx["report"]["checks"].append(value) + save(ctx) + if value["status"] == "fail": + raise AssertionError(f"{name}: intended {intended}; known prior {prior}; got {actual}") + return value + + +def configure(ctx): + output = ctx["output"] + config_dir = ctx["config"] + config_dir.mkdir(parents=True) + options = {"updateCheck": False, "experimentalV2Leader": "space"} + fixture = {"state": str(ctx["state"]), "request": str(ctx["request"]), + "command": str(ctx["command"]), "result": str(ctx["result"]), + "directory": str(output / "sandbox")} + spec = f"vimcode@file:{ctx['tarball']}" + if ctx["args"].host == "v1": + config = {"plugin": [[spec, options], [str(FIXTURE / "tui.ts"), fixture]], + "keybinds": {"leader": "space", "command_list": "ctrl+p,p"}} + file = config_dir / "tui.json" + else: + config = {"plugins": [{"package": spec, "options": options}, {"package": str(FIXTURE), "options": fixture}], + "keybinds": {"leader": "space", "command.palette.show": ["ctrl+p", "p"]}} + file = config_dir / "cli.json" + atomic_json(file, config) + ctx["config_file"] = file + (output / "sandbox").mkdir() + (output / "bin").mkdir() + # Never touch the user's clipboard, even if a future key scenario + # accidentally executes a native paste rather than a Vim motion. + for name in ("pbcopy", "wl-copy", "xclip", "xsel", "clip.exe"): + tool = output / "bin" / name + tool.write_text("#!/bin/sh\nexec cat > " + shlex.quote(str(output / "clipboard.txt")) + "\n") + tool.chmod(0o755) + for name in ("pbpaste", "wl-paste"): + tool = output / "bin" / name + tool.write_text("#!/bin/sh\nexit 0\n") + tool.chmod(0o755) + + +def start(ctx, label): + # Never accept a previous process's ready/seed/command snapshot. + for path in (ctx["state"], ctx["request"], ctx["command"], ctx["result"]): + path.unlink(missing_ok=True) + output = ctx["output"] + env = {"PATH": str(output / "bin") + os.pathsep + "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": str(output / "home"), "XDG_CONFIG_HOME": str(output / "config"), + "XDG_DATA_HOME": str(output / "data"), "XDG_CACHE_HOME": str(output / "cache"), + "XDG_STATE_HOME": str(output / "state"), "OPENCODE_DISABLE_AUTOUPDATE": "1", + "TERM": "xterm-256color", "COLORTERM": "truecolor"} + for folder in ("home", "data", "cache", "state"): + (output / folder).mkdir(exist_ok=True) + binary_args = [str(ctx["args"].binary)] + if ctx["args"].host == "v2": + binary_args.append("--standalone") + binary_args.append(str(output / "sandbox")) + command = ["env", "-i", *[f"{key}={value}" for key, value in env.items()], *binary_args] + command_line = "exec " + shlex.join(command) + " 2>" + shlex.quote(str(output / f"{label}-stderr.log")) + tmux(ctx, "new-session", "-d", "-s", ctx["session"], "-x", "110", "-y", "34", + "-c", str(output / "sandbox"), "/bin/sh", "-c", command_line) + tmux(ctx, "set-option", "-w", "-t", ctx["session"], "window-size", "manual") + tmux(ctx, "resize-window", "-t", ctx["session"], "-x", "110", "-y", "34") + + +def stop(ctx): + try: + tmux(ctx, "kill-session", "-t", ctx["session"]) + return True + except (OSError, subprocess.SubprocessError) as error: + if isinstance(error, subprocess.CalledProcessError): + message = error.stderr or "" + if any(text in message for text in ("can't find session", "no server running", "No such file")): + return True + ctx["report"].setdefault("cleanupErrors", []).append({ + "socket": ctx["socket"], "session": ctx["session"], "error": str(error)[:500], + }) + return False + + +def identity(ctx): + tarball = ctx["tarball"] + with tarfile.open(tarball) as archive: + members = archive.getmembers() + if any(member.name.startswith("package/test/") or "compat/fixture" in member.name for member in members): + raise RuntimeError("Test fixtures must not be shipped in the tarball") + expected = { + member.name.removeprefix("package/"): archive.extractfile(member).read() + for member in members + if member.isfile() and (member.name == "package/package.json" or member.name.startswith("package/src/")) + } + if "src/index.ts" not in expected or "src/v2.ts" not in expected or "package.json" not in expected: + raise RuntimeError(f"Tarball is missing the dual entrypoint: {sorted(expected)}") + entries = list((ctx["output"] / "cache" / "opencode").rglob("node_modules/vimcode/src/index.ts")) + if len(entries) != 1: + raise RuntimeError(f"Expected exactly one cache-installed vimcode, got: {entries}") + root = entries[0].parent.parent + for relative, content in expected.items(): + if not (root / relative).exists() or (root / relative).read_bytes() != content: + raise RuntimeError(f"Installed artifact mismatch: {root / relative}") + return {"entry": str(entries[0]), "verifiedFiles": + {name: hashlib.sha256(content).hexdigest() for name, content in sorted(expected.items())}} + + +def ready(ctx, label): + try: + wait_for(lambda: snapshot(ctx), lambda value: value.get("ready") is True, "registered vimcode.vim command", 20) + wait_for(lambda: capture(ctx), lambda value: "Ask anything" in value, "visible TUI prompt", 8) + for _ in range(5): + tmux(ctx, "send-keys", "-t", ctx["pane"], "C-p") + try: + wait_for(lambda: capture(ctx), lambda value: "Commands" in value, "command palette", 1) + break + except RuntimeError: + time.sleep(0.2) + else: + raise RuntimeError("Cannot open host command palette with ctrl+p") + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "vim") + pane = wait_for(lambda: capture(ctx), lambda value: ":vim" in value and "Commands" in value, + "vimcode command in palette", 8) + (ctx["output"] / f"{label}-palette.txt").write_text(pane) + tmux(ctx, "send-keys", "-t", ctx["pane"], "Escape") + wait_for(lambda: snapshot(ctx), lambda value: value.get("mode") in ("base", "unknown") and + value.get("focusedId") == value.get("editorId"), "editor after palette dismissal", 5) + wait_for(lambda: capture(ctx), lambda value: "Commands" not in value, "palette closed", 5) + time.sleep(0.2) + return {"status": "passed", **identity(ctx)} + except Exception as error: + (ctx["output"] / f"{label}-screen.txt").write_text(capture(ctx)) + return {"status": "failed", "reason": str(error)[:600]} + + +def normal(ctx): + # A completed autocomplete key can immediately reopen completion for + # the remaining slash. Its first Escape hides it; the next enters Vim. + for _ in range(3): + state = snapshot(ctx) + if state.get("mode") != "autocomplete" and state.get("cursorStyle", {}).get("style") == "block": + return + send(ctx, "Escape", False) + wait_for(lambda: snapshot(ctx), lambda value: value.get("mode") != "autocomplete" and + value.get("cursorStyle", {}).get("style") == "block", "Vim normal cursor", 3) + + +def seed(ctx, text, offset=0): + normal(ctx) + uid = uuid.uuid4().hex + atomic_json(ctx["request"], {"action": "seed", "id": uid, "text": text, "offset": offset}) + state = wait_for(lambda: snapshot(ctx), lambda value: value.get("applied") == uid, "seeded editor", 5) + if state["text"] != text or state["offset"] != offset or state["afterSeed"]["selection"] is not None: + raise AssertionError(f"Seed was not clean: {state}") + return state + + +def command(ctx, action): + uid = uuid.uuid4().hex + atomic_json(ctx["command"], {"id": uid, "action": action}) + response = wait_for(lambda: read_json(ctx["result"]), lambda value: value["id"] == uid, action, 8) + if "error" in response: + raise RuntimeError(f"Fixture {action}: {response['error']}") + return response["result"] diff --git a/test/compat/fixture/package.json b/test/compat/fixture/package.json new file mode 100644 index 0000000..5d00ed1 --- /dev/null +++ b/test/compat/fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "vimcode-compat-fixture", + "private": true, + "type": "module", + "exports": { + "./tui": "./tui.ts" + } +} diff --git a/test/compat/fixture/tui.ts b/test/compat/fixture/tui.ts new file mode 100644 index 0000000..28104c7 --- /dev/null +++ b/test/compat/fixture/tui.ts @@ -0,0 +1,262 @@ +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; + +type Editor = { + id?: string; + plainText: string; + cursorOffset: number; + visualCursor?: { logicalRow: number; logicalCol: number; offset: number }; + cursorStyle?: { style: string; blinking: boolean }; + getSelection?: () => { start: number; end: number } | null; + clearSelection?: () => boolean; + setText?: (text: string) => void; + editBuffer?: { setText: (text: string) => void }; + editorView?: { + getSelection?: () => { start: number; end: number } | null; + getSelectedText?: () => string; + resetSelection: () => void; + }; +}; +type Renderer = { + currentFocusedEditor?: Editor; + currentFocusedRenderable?: Editor; + requestRender?: () => void; + keyInput?: { listeners(event: "keypress"): readonly unknown[] }; +}; +type Options = { state: string; request: string; command: string; result: string; directory: string }; +type Route = { type?: string; name?: string; sessionID?: string }; +type V1Api = { + renderer: Renderer; + plugins: { list(): readonly { id: string; active: boolean }[] }; + route: { current: Route }; + mode?: { current(): string }; + lifecycle: { onDispose(fn: () => void): void }; +}; +type Session = { id: string; parentID?: string }; +type Form = { id: string; state?: { status: string; answer?: Record } }; +type V2Api = { + options: Options; + renderer: Renderer; + keymap: { commands(): readonly { id?: string }[]; mode: { current(): string }; pending(): readonly unknown[] }; + ui: { + router: { current(): Route; navigate(route: { type: "session"; sessionID: string } | { type: "home" }): void }; + }; + client: { + session: { + create(input: { + title: string; + location: { directory: string }; + permissions: readonly { action: string; resource: string; effect: "ask" }[]; + }): Promise; + export(input: { sessionID: string }): Promise<{ info: Session; messages: unknown[] }>; + import(input: { info: Session; messages: unknown[]; location: { directory: string } }): Promise; + form: { + create(input: { + sessionID: string; + title: string; + fields: readonly { key: string; type: "string"; title: string; required: true }[]; + }): Promise
; + get(input: { sessionID: string; formID: string }): Promise; + }; + }; + permission: { + create(input: { + sessionID: string; + action: string; + resources: string[]; + }): Promise<{ id: string; effect: string }>; + list(input: { sessionID: string }): Promise; + }; + }; +}; +type Request = { id: string; action: "seed"; text: string; offset: number } | { id: string; action: "observe" }; +type Command = { + id: string; + action: "session" | "home" | "form" | "childForm" | "formState" | "permission" | "permissions"; +}; + +function readRequest(file: string): T | undefined { + if (!existsSync(file)) return; + try { + return JSON.parse(readFileSync(file, "utf8")) as T; + } catch { + // The runner writes atomically. A partial file indicates a test fault; + // the runner times out with its last observed state rather than editing. + return; + } +} + +function writeResult(file: string, value: unknown) { + writeFileSync(`${file}.next`, JSON.stringify(value)); + renameSync(`${file}.next`, file); +} + +function observe( + renderer: Renderer, + options: Options, + ready: () => boolean, + route: () => Route, + mode: () => string, + pending: () => readonly unknown[], + extras?: (command: Command) => Promise, +) { + let applied: string | undefined; + let observed: string | undefined; + let responded: string | undefined; + let afterSeed: unknown; + let beforeSeed: unknown; + let busy = false; + let previous = ""; + const timer = setInterval(() => { + const editor = renderer.currentFocusedEditor; + const request = readRequest(options.request); + if (request?.action === "observe") observed = request.id; + if ( + request?.action === "seed" && + request.id !== applied && + editor && + editor === renderer.currentFocusedRenderable && + (mode() === "base" || mode() === "unknown") + ) { + beforeSeed = { + text: editor.plainText, + selection: editor.getSelection?.() ?? editor.editorView?.getSelection?.() ?? null, + }; + // Use the renderable's public reset methods. Avoid leaving a stale + // selection in the editorView when replacing the native edit buffer. + editor.clearSelection?.(); + if (editor.setText) editor.setText(request.text); + else editor.editBuffer?.setText(request.text); + editor.cursorOffset = request.offset; + editor.clearSelection?.(); + renderer.requestRender?.(); + afterSeed = { + text: editor.plainText, + selection: editor.getSelection?.() ?? editor.editorView?.getSelection?.() ?? null, + }; + applied = request.id; + } + const command = readRequest(options.command); + if (extras && command && command.id !== responded && !busy) { + busy = true; + responded = command.id; + void extras(command) + .then((result) => writeResult(options.result, { id: command.id, result })) + .catch((error: unknown) => writeResult(options.result, { id: command.id, error: String(error) })) + .finally(() => { + busy = false; + }); + } + const cursor = editor?.visualCursor; + const snapshot = { + ready: ready(), + applied, + observed, + ownsFocus: !!editor && editor === renderer.currentFocusedRenderable, + beforeSeed, + afterSeed, + text: editor?.plainText ?? null, + offset: editor?.cursorOffset ?? null, + cursor: cursor ? { logicalRow: cursor.logicalRow, logicalCol: cursor.logicalCol, offset: cursor.offset } : null, + selection: editor?.getSelection?.() ?? editor?.editorView?.getSelection?.() ?? null, + selected: editor?.editorView?.getSelectedText?.() ?? null, + cursorStyle: editor?.cursorStyle ?? null, + keypressListeners: renderer.keyInput?.listeners("keypress").length ?? null, + editorId: editor?.id ?? null, + focusedId: renderer.currentFocusedRenderable?.id ?? null, + mode: mode(), + route: route(), + pending: pending(), + }; + const next = JSON.stringify(snapshot); + if (next === previous) return; + writeResult(options.state, snapshot); + previous = next; + }, 40); + return () => clearInterval(timer); +} + +export default { + id: "vimcode-compat-fixture", + async tui(api: V1Api, options: Options) { + api.lifecycle.onDispose( + observe( + api.renderer, + options, + () => api.plugins.list().some((entry) => entry.id === "vimcode" && entry.active), + () => api.route.current, + () => api.mode?.current() ?? "unknown", + () => [], + ), + ); + }, + setup(context: V2Api) { + const api = context.client; + let session: Session | undefined; + let form: Form | undefined; + const commands = async (command: Command) => { + if (command.action === "session") { + session = await api.session.create({ + title: "Synthetic vimcode compatibility test", + location: { directory: context.options.directory }, + permissions: [{ action: "compat.fixture", resource: "*", effect: "ask" }], + }); + context.ui.router.navigate({ type: "session", sessionID: session.id }); + return { sessionID: session.id }; + } + if (command.action === "home") { + context.ui.router.navigate({ type: "home" }); + return { home: true }; + } + if (!session) throw new Error("Create a synthetic session first"); + if (command.action === "childForm") { + const rootID = session.id; + const transfer = await api.session.export({ sessionID: rootID }); + session = await api.session.import({ + info: { + ...transfer.info, + id: `ses_compat_child_${Date.now()}`, + parentID: rootID, + title: "Synthetic child form", + }, + messages: [], + location: { directory: context.options.directory }, + }); + context.ui.router.navigate({ type: "session", sessionID: rootID }); + form = await api.session.form.create({ + sessionID: session.id, + title: "Synthetic child form", + fields: [{ key: "answer", type: "string", title: "Child answer", required: true }], + }); + return { rootID, childID: session.id, formID: form.id }; + } + if (command.action === "form") { + form = await api.session.form.create({ + sessionID: session.id, + title: "Synthetic form", + fields: [{ key: "answer", type: "string", title: "Answer", required: true }], + }); + return { sessionID: session.id, formID: form.id }; + } + if (command.action === "formState") { + if (!form) throw new Error("Create a synthetic form first"); + return api.session.form.get({ sessionID: session.id, formID: form.id }); + } + if (command.action === "permission") + return api.permission.create({ + sessionID: session.id, + action: "compat.fixture", + resources: ["synthetic-resource"], + }); + return api.permission.list({ sessionID: session.id }); + }; + return observe( + context.renderer, + context.options, + () => context.keymap.commands().some((entry) => entry.id === "vimcode.vim"), + () => context.ui.router.current(), + () => context.keymap.mode.current(), + () => context.keymap.pending(), + commands, + ); + }, +}; diff --git a/test/compat/run.py b/test/compat/run.py new file mode 100644 index 0000000..1b1aa5d --- /dev/null +++ b/test/compat/run.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Real-host compatibility probe. Never sends a prompt or approves a permission.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +from cases import run_core, run_v2_prompts, run_v2_reload +from driver import capture, configure, new_context, ready, save, start, stop + +ROOT = Path(__file__).resolve().parents[2] + + +def finalize(ctx): + try: + try: + (ctx["output"] / "last-screen.txt").write_text(capture(ctx)) + except (OSError, subprocess.SubprocessError): + pass + if not stop(ctx): + ctx["report"]["result"] = "failed" + ctx["report"].setdefault("failure", "Owned test session cleanup could not be confirmed") + finally: + checks = ctx["report"]["checks"] + ctx["report"]["summary"] = { + "pass": sum(check["status"] == "pass" for check in checks), + "known-gap": [check["name"] for check in checks if check["status"] == "known-gap"], + "fail": [check["name"] for check in checks if check["status"] == "fail"], + } + save(ctx) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", choices=("v1", "v2"), required=True) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--expect-version", required=True) + parser.add_argument("--output", type=Path, required=True, help="a new, empty directory outside a project") + args = parser.parse_args() + if sys.platform not in ("darwin", "linux") or not shutil.which("tmux") or not shutil.which("npm"): + parser.error("Requires macOS/Linux/WSL with tmux, npm and Python 3; native Windows is untested") + args.binary = args.binary.resolve() + if not args.binary.is_file() or not os.access(args.binary, os.X_OK): + parser.error("--binary must name an executable file") + try: + version = subprocess.check_output([str(args.binary), "--version"], text=True, timeout=10).strip().removeprefix("opencode v") + except (OSError, subprocess.SubprocessError) as error: + parser.error(f"Could not determine OpenCode binary version: {error}") + if version != args.expect_version: + parser.error(f"Binary version {version!r} does not match --expect-version {args.expect_version!r}") + args.actual_version = version + + output = args.output.resolve() + for ancestor in (output, *output.parents): + if (ancestor / ".git").exists() or any((ancestor / name).exists() for name in (".opencode", "opencode.json", "opencode.jsonc", "tui.json", "cli.json")): + parser.error(f"--output must be outside a checkout and project OpenCode config: {ancestor}") + if output.exists() and any(output.iterdir()): + parser.error(f"--output must be new or empty: {output}") + output.mkdir(parents=True, exist_ok=True) + build = output / "build" + build.mkdir() + try: + with (output / "pack.log").open("w") as log: + subprocess.run(["npm", "pack", "--ignore-scripts", "--pack-destination", str(build)], cwd=ROOT, + check=True, stdout=subprocess.DEVNULL, stderr=log, timeout=30) + except (OSError, subprocess.SubprocessError) as error: + parser.error(f"Could not package the plugin; see {output / 'pack.log'}: {error}") + tarball = build / f"vimcode-{json.loads((ROOT / 'package.json').read_text())['version']}.tgz" + ctx = new_context(args, output, tarball) + configure(ctx) + save(ctx) + try: + start(ctx, "cold") + ctx["report"]["coldInstall"] = ready(ctx, "cold") + save(ctx) + if not stop(ctx): + raise RuntimeError("Could not confirm cleanup of the cold test session") + # A failed cold install remains a failure, even when warm replay + # succeeds. The second run distinguishes installer from input bugs. + start(ctx, "warm") + ctx["report"]["warmInstall"] = ready(ctx, "warm") + save(ctx) + if ctx["report"]["warmInstall"]["status"] != "passed": + raise RuntimeError("Warm installation did not register vimcode") + run_core(ctx) + if args.host == "v2": + run_v2_prompts(ctx) + run_v2_reload(ctx) + ctx["report"]["result"] = "passed" if ctx["report"]["coldInstall"]["status"] == "passed" else "cold-install-failed" + except Exception as error: + ctx["report"]["result"] = "failed" + ctx["report"]["failure"] = str(error)[:900] + finally: + finalize(ctx) + summary = ctx["report"]["summary"] + print(f"{ctx['report']['result']}: {summary['pass']} passing, {len(summary['known-gap'])} known gaps, " + f"{len(summary['fail'])} failed; {output / 'receipt.json'}") + return 0 if ctx["report"]["result"] == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/compat/test_results.py b/test/compat/test_results.py new file mode 100644 index 0000000..b39a570 --- /dev/null +++ b/test/compat/test_results.py @@ -0,0 +1,94 @@ +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from driver import compare_state, fresh_snapshot, stop +from run import finalize + + +def test_substring_is_not_a_visual_selection_pass(): + assert compare_state("visual", {"text": "pha beta gamma"}, {"text": "ha beta gamma"})["status"] == "fail" + + +def test_known_gap_is_distinct_from_a_pass(): + assert compare_state("redo", {"text": "alpha beta gamma"}, {"text": "alpha gamma"}, + {"text": "alpha beta gamma"})["status"] == "known-gap" + + +def test_repaired_gap_is_a_pass(): + assert compare_state("redo", {"text": "alpha gamma"}, {"text": "alpha gamma"}, + {"text": "alpha beta gamma"})["status"] == "pass" + + +def test_unknown_regression_is_a_failure(): + assert compare_state("redo", {"text": "garbled"}, {"text": "alpha gamma"}, + {"text": "alpha beta gamma"})["status"] == "fail" + + +def test_correct_text_with_wrong_cursor_fails(): + assert compare_state("cursor", {"text": "abc", "offset": 1}, + {"text": "abc", "offset": 0})["status"] == "fail" + + +def test_stale_selection_fails(): + assert compare_state("selection", {"text": "abc", "selection": {"start": 0, "end": 1}}, + {"text": "abc", "selection": None})["status"] == "fail" + + +def test_lost_focus_fails(): + assert compare_state("focus", {"text": "abc", "ownsFocus": False}, + {"text": "abc", "ownsFocus": True})["status"] == "fail" + + +def test_stale_observer_acknowledgement_times_out(): + with tempfile.TemporaryDirectory() as directory: + ctx = {"request": Path(directory) / "request.json"} + with patch("driver.snapshot", return_value={"observed": "old"}): + try: + fresh_snapshot(ctx, seconds=0.01) + except RuntimeError as error: + assert "fresh observer acknowledgement" in str(error) + else: + raise AssertionError("A stale observation was accepted") + + +def test_fresh_observer_acknowledgement_is_required(): + with tempfile.TemporaryDirectory() as directory: + request = Path(directory) / "request.json" + with patch("driver.snapshot", side_effect=lambda _: {"observed": json.loads(request.read_text())["id"]}): + value = fresh_snapshot({"request": request}) + assert value["observed"] == json.loads(request.read_text())["id"] + + +def test_cleanup_failures_preserve_a_failing_final_receipt(): + for error in (subprocess.TimeoutExpired(["tmux"], 8), OSError("synthetic shutdown failure")): + with tempfile.TemporaryDirectory() as directory: + ctx = {"output": Path(directory), "socket": "owned-socket", "session": "owned-session", + "pane": "owned-session:0.0", "report": {"result": "passed", "checks": []}} + with patch("driver.tmux", side_effect=error): + finalize(ctx) + receipt = json.loads((Path(directory) / "receipt.json").read_text()) + assert receipt["result"] == "failed" + assert receipt["cleanupErrors"][0]["socket"] == "owned-socket" + assert receipt["cleanupErrors"][0]["session"] == "owned-session" + assert receipt["summary"] == {"pass": 0, "known-gap": [], "fail": []} + + +def test_already_closed_owned_session_is_not_a_cleanup_failure(): + ctx = {"socket": "owned-socket", "session": "owned-session", "report": {}} + error = subprocess.CalledProcessError(1, ["tmux"], stderr="can't find session: owned-session") + with patch("driver.tmux", side_effect=error): + assert stop(ctx) is True + assert "cleanupErrors" not in ctx["report"] + + +def load_tests(_loader, _tests, _pattern): + functions = [value for name, value in globals().items() if name.startswith("test_") and callable(value)] + return unittest.TestSuite(unittest.FunctionTestCase(function) for function in functions) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/editor.test.ts b/test/editor.test.ts new file mode 100644 index 0000000..621a2e5 --- /dev/null +++ b/test/editor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { selectVisualCharacterRange } from "../src/editor"; + +describe("selectVisualCharacterRange", () => { + it("includes the cursor without clearing the renderer's native selection anchor", () => { + const calls: number[][] = []; + let renders = 0; + let clearedAnchor = false; + const editor = { + cursorOffset: 2, + setSelection() { + clearedAnchor = true; + }, + editorView: { setSelection: (start: number, end: number) => calls.push([start, end]) }, + requestRender: () => renders++, + }; + selectVisualCharacterRange(editor, 0); + expect(calls).toEqual([[0, 3]]); + expect(clearedAnchor).toBe(false); + expect(renders).toBe(1); + }); + + it("keeps the original anchor when moving backward", () => { + const calls: number[][] = []; + selectVisualCharacterRange( + { cursorOffset: 2, editorView: { setSelection: (start, end) => calls.push([start, end]) } }, + 4, + ); + expect(calls).toEqual([[2, 5]]); + }); + + it("leaves unsupported editors alone instead of using an anchor-clearing fallback", () => { + let renders = 0; + selectVisualCharacterRange({ cursorOffset: 1, requestRender: () => renders++ }, 0); + expect(renders).toBe(0); + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index e208911..7ffe85e 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -417,6 +417,87 @@ describe("undo snapshot — deleteRange + u", () => { }); }); +describe("visual character selection dispatch guard", () => { + it("normalizes only successful motions on the same focused editor while visual remains active", async () => { + const plugin = (await import("../src/index")).default; + const selections: Array<[number, number]> = []; + const disposers: Array<() => void> = []; + let handler: ((input: { event: { name: string; eventType: string }; consume: () => void }) => void) | undefined; + let ok = true; + const dispatched: string[] = []; + const editor = { + plainText: "hello", + cursorOffset: 2, + visualCursor: { logicalRow: 0 }, + cursorStyle: { style: "line", blinking: true }, + editorView: { + setSelection(start: number, end: number) { + selections.push([start, end]); + }, + }, + }; + const renderer: { currentFocusedEditor: typeof editor; currentFocusedRenderable?: typeof editor } = { + currentFocusedEditor: editor, + currentFocusedRenderable: editor, + }; + const api = { + renderer, + ui: { toast: () => {}, dialog: { open: false } }, + route: { current: { name: "home" } }, + state: { session: { question: () => [], permission: () => [] } }, + kv: { get: async () => undefined }, + lifecycle: { onDispose: (dispose: () => void) => disposers.push(dispose) }, + keymap: { + intercept: (_kind: string, callback: typeof handler) => { + handler = callback; + }, + dispatchCommand: (cmd: string) => { + dispatched.push(cmd); + return { ok: cmd.startsWith("prompt.autocomplete.") ? false : ok }; + }, + }, + }; + await plugin.tui(api as unknown as Parameters[0], { updateCheck: false }); + const press = (name: string) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press" }, + consume: () => { + consumed = true; + }, + }); + return consumed; + }; + try { + expect(press("escape")).toBe(true); + expect(press("v")).toBe(true); + expect(press("l")).toBe(true); + await Bun.sleep(20); + expect(dispatched).toContain("input.select.right"); + expect(selections).toEqual([[1, 2]]); + + ok = false; + press("l"); + await Bun.sleep(20); + expect(selections).toHaveLength(1); + + ok = true; + press("l"); + renderer.currentFocusedRenderable = undefined; + await Bun.sleep(20); + expect(selections).toHaveLength(1); + + renderer.currentFocusedRenderable = editor; + press("l"); + press("v"); // leave visual before the deferred dispatch runs + await Bun.sleep(20); + expect(selections).toHaveLength(1); + } finally { + for (const dispose of disposers) dispose(); + } + }); +}); + // ── arrow keys pass through the intercept (issue #63) ───── describe("arrow keys pass through the intercept", () => { diff --git a/test/v2.test.ts b/test/v2.test.ts new file mode 100644 index 0000000..b1e8e11 --- /dev/null +++ b/test/v2.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import plugin from "../src/index"; +import type { V2Context } from "../src/v2"; + +type Press = { name: string; ctrl?: boolean; shift?: boolean; eventType?: string }; +type RawPressEvent = Press & { preventDefault(): void; stopPropagation(): void }; +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function host(options: Record = {}, saved = { disabled: false, lastUpdateCheck: "" }) { + const pressed: Array<(event: RawPressEvent) => void> = []; + const commands: Array<{ id: string; run: () => void | Promise; slash?: { name: string } }> = []; + const dispatched: string[] = []; + const toasts: string[] = []; + const eventHandlers = new Map< + string, + (event: { data: { sessionID?: string; form?: { sessionID: string } } }) => void + >(); + const pendingForms = new Map(); + const pendingPermissions = new Map(); + let mode = "base"; + let route: { type: string; sessionID?: string } = { type: "home" }; + let focused = true; + let slotRender: (() => null) | undefined; + let removedSlot = false; + const editor = { + plainText: "hello", + cursorOffset: 2, + visualCursor: { logicalRow: 0 }, + cursorStyle: { style: "line", blinking: true }, + insertText(value: string) { + this.plainText += value; + }, + }; + const renderer = { + currentFocusedEditor: editor, + get currentFocusedRenderable() { + return focused ? editor : undefined; + }, + keyInput: { + prependListener(_name: "keypress", listener: (event: RawPressEvent) => void) { + pressed.unshift(listener); + }, + off(_name: "keypress", listener: (event: RawPressEvent) => void) { + const index = pressed.indexOf(listener); + if (index >= 0) pressed.splice(index, 1); + }, + }, + }; + const context: V2Context = { + options, + renderer, + keymap: { + mode: { current: () => mode }, + dispatch: (id) => { + dispatched.push(id); + }, + layer: (input) => { + commands.push(...input().commands); + }, + }, + storage: { + store: (_key: string, _options: { initial: T }) => { + const value = saved as unknown as T; + return [ + value, + async (update: (draft: T) => void) => { + update(value); + }, + ] as const; + }, + }, + data: { + on: (name, handler) => { + eventHandlers.set(name, handler); + return () => { + eventHandlers.delete(name); + }; + }, + session: { + root: (id) => (id === "child" ? "root" : id), + family: (id) => (id === "root" ? ["root", "child"] : [id]), + form: { list: (id) => pendingForms.get(id) ?? [] }, + permission: { list: (id) => pendingPermissions.get(id) ?? [] }, + }, + }, + ui: { + toast: { + show: (input) => { + toasts.push(input.message); + }, + }, + router: { current: () => route }, + slot: ({ render }) => { + slotRender = render; + return () => { + removedSlot = true; + }; + }, + }, + }; + function press(name: string, flags: Omit = {}) { + let prevented = false; + let stopped = false; + const event = { + name, + ...flags, + preventDefault() { + prevented = true; + }, + stopPropagation() { + stopped = true; + }, + }; + for (const listener of [...pressed]) { + listener(event); + if (stopped) break; + } + return { prevented, stopped }; + } + function emit(type: string, data: { sessionID?: string; form?: { sessionID: string } }) { + eventHandlers.get(type)?.({ data }); + } + return { + context, + press, + emit, + editor, + commands, + dispatched, + toasts, + saved, + pendingForms, + pendingPermissions, + renderSlot: () => slotRender?.(), + setMode: (value: string) => { + mode = value; + }, + setRoute: (value: typeof route) => { + route = value; + }, + setFocused: (value: boolean) => { + focused = value; + }, + get listenerCount() { + return pressed.length; + }, + get removedSlot() { + return removedSlot; + }, + get eventCount() { + return eventHandlers.size; + }, + }; +} + +async function start(mock: ReturnType) { + const cleanup = await plugin.setup(mock.context); + cleanups.push(cleanup); + mock.renderSlot(); + return cleanup; +} + +describe("OpenCode v2 POC facade", () => { + it("one ./tui default export exposes both v1 tui and v2 setup without v2 runtime imports", () => { + expect(plugin.id).toBe("vimcode"); + expect(plugin.tui).toBeFunction(); + expect(plugin.setup).toBeFunction(); + }); + + it("consumes Escape and all unknown normal chars including Unicode before the host keymap", async () => { + const mock = host({ updateCheck: false }); + await start(mock); + expect(mock.listenerCount).toBe(1); + expect(mock.press("escape")).toEqual({ prevented: true, stopped: true }); + expect(mock.press("🤖")).toEqual({ prevented: true, stopped: true }); + expect(mock.press("up")).toEqual({ prevented: false, stopped: false }); + expect(mock.press("h")).toEqual({ prevented: true, stopped: true }); + await Bun.sleep(20); + expect(mock.dispatched).toContain("input.move.left"); + expect(mock.press("escape", { eventType: "release" })).toEqual({ prevented: false, stopped: false }); + }); + + it("does not steal keys from other routes, unfocused editors, dialogs, forms or autocomplete in normal mode", async () => { + const mock = host({ updateCheck: false }); + await start(mock); + mock.press("escape"); + mock.setFocused(false); + expect(mock.press("h").stopped).toBe(false); + mock.setFocused(true); + mock.setRoute({ type: "plugin" }); + expect(mock.press("h").stopped).toBe(false); + mock.setRoute({ type: "home" }); + for (const mode of ["modal", "form", "autocomplete"]) { + mock.setMode(mode); + expect(mock.press("h").stopped).toBe(false); + } + mock.setMode("base"); + expect(mock.press("h").stopped).toBe(true); + }); + + it("inserts a printable leader into a focused form without interpreting other form keys as Vim commands", async () => { + const mock = host({ updateCheck: false, experimentalV2Leader: "space" }); + await start(mock); + mock.press("escape"); + mock.setMode("form"); + expect(mock.press("space")).toEqual({ prevented: true, stopped: true }); + expect(mock.editor.plainText).toBe("hello "); + expect(mock.press("h").stopped).toBe(false); + expect(mock.press("return").stopped).toBe(false); + mock.setFocused(false); + expect(mock.press("space").stopped).toBe(false); + + const disabled = host( + { updateCheck: false, experimentalV2Leader: "space" }, + { disabled: true, lastUpdateCheck: "" }, + ); + await start(disabled); + disabled.setMode("form"); + expect(disabled.press("space").stopped).toBe(false); + }); + + it("uses host autocomplete state for Enter/Escape in insert mode without intercepting typed keys", async () => { + const mock = host({ updateCheck: false }); + await start(mock); + mock.setMode("autocomplete"); + expect(mock.press("a").stopped).toBe(false); + expect(mock.press("return").stopped).toBe(true); + expect(mock.dispatched).toContain("prompt.autocomplete.select"); + expect(mock.press("escape").stopped).toBe(true); + expect(mock.dispatched).toContain("prompt.autocomplete.hide"); + mock.setMode("base"); + expect(mock.press("escape").stopped).toBe(true); + expect(mock.toasts).toContain("NORMAL"); + }); + + it("passes a matching space leader and next key in normal, inserts it in insert; disabling mirror consumes it", async () => { + const mock = host({ updateCheck: false, experimentalV2Leader: "space" }); + await start(mock); + expect(mock.press("space").stopped).toBe(true); + expect(mock.editor.plainText).toBe("hello "); + mock.press("escape"); + expect(mock.press("space").stopped).toBe(false); + expect(mock.press("h").stopped).toBe(false); + expect(mock.press("h").stopped).toBe(true); + const noLeader = host({ updateCheck: false, experimentalV2Leader: false }); + await start(noLeader); + noLeader.press("escape"); + expect(noLeader.press("space").stopped).toBe(true); + }); + + it("passes default ctrl+x leader and maps v2 form/permission events for child sessions", async () => { + const mock = host({ updateCheck: false }); + await start(mock); + mock.press("escape"); + expect(mock.press("x", { ctrl: true }).stopped).toBe(false); + expect(mock.press("h").stopped).toBe(false); + mock.setRoute({ type: "session", sessionID: "root" }); + mock.emit("form.created", { form: { sessionID: "child" } }); + expect(mock.press("h").stopped).toBe(false); + mock.emit("form.cancelled", { sessionID: "child" }); + expect(mock.press("h").stopped).toBe(true); + mock.emit("form.created", { form: { sessionID: "child" } }); + mock.emit("form.replied", { sessionID: "child" }); + expect(mock.press("h").stopped).toBe(true); + mock.pendingPermissions.set("child", [{}]); + expect(mock.press("h").stopped).toBe(false); + mock.pendingPermissions.delete("child"); + mock.emit("permission.asked", { sessionID: "child" }); + expect(mock.press("h").stopped).toBe(false); + mock.emit("permission.replied", { sessionID: "child" }); + expect(mock.press("h").stopped).toBe(true); + }); + + it("registers v2 palette/slash commands with an app slot owner, persists toggle and removes resources", async () => { + const saved = { disabled: true, lastUpdateCheck: "" }; + const mock = host({ updateCheck: false }, saved); + const cleanup = await start(mock); + expect(mock.toasts).toContain("Vim mode disabled (use /vim to re-enable)"); + expect(mock.press("escape").stopped).toBe(false); + expect(mock.commands.map((command) => command.id)).toContain("vimcode.vim"); + expect(mock.commands.find((command) => command.id === "vimcode.write")?.slash?.name).toBe("write"); + await mock.commands.find((command) => command.id === "vimcode.write")?.run(); + await Bun.sleep(20); + expect(mock.dispatched).toContain("input.submit"); + await mock.commands.find((command) => command.id === "vimcode.vim")?.run(); + expect(mock.saved.disabled).toBe(false); + expect(mock.press("escape").stopped).toBe(true); + cleanup(); + cleanup(); + expect(mock.listenerCount).toBe(0); + expect(mock.eventCount).toBe(0); + expect(mock.removedSlot).toBe(true); + expect(mock.press("h").stopped).toBe(false); + }); +}); diff --git a/tui.ts b/tui.ts new file mode 100644 index 0000000..02102e6 --- /dev/null +++ b/tui.ts @@ -0,0 +1,2 @@ +// Local v2 directory plugins resolve a root tui file, not package exports. +export { default } from "./src/index"; From 1e66e059027f32c33a8501499bc876bdbd37a23a Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 1 Oct 2026 21:42:09 +0300 Subject: [PATCH 2/4] fix: resolve v1 and v2 compatibility review findings --- .github/workflows/test.yml | 4 ++ AGENTS.md | 16 ++++--- CHANGELOG.md | 6 ++- docs/opencode-v2-poc.md | 10 ++-- src/editor.ts | 18 ++++++-- src/index.ts | 24 ++++++++++ src/v2.ts | 8 +++- test/compat/cases.py | 61 ++++++++++++++++++++---- test/compat/driver.py | 7 +-- test/compat/fixture/tui.ts | 3 +- test/compat/test_driver.py | 73 +++++++++++++++++++++++++++++ test/editor.test.ts | 95 +++++++++++++++++++++++++++++--------- test/integration.test.ts | 2 + test/v2.test.ts | 73 ++++++++++++++++++++++++++++- 14 files changed, 346 insertions(+), 54 deletions(-) create mode 100644 test/compat/test_driver.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5799b9d..cf3b856 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7004d71..4db9867 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,9 +58,9 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (450 lines) Dual v1 tui/v2 setup entry: intercept registration, action application - editor.ts (20 lines) Visual character selection normalization at the host boundary - v2.ts (239 lines) Experimental v2 TUI facade (host input, commands, state, events) + index.ts (474 lines) Dual v1 tui/v2 setup entry: intercept registration, action application + editor.ts (30 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 @@ -86,9 +86,9 @@ test/ normal.test.ts (823) handleNormalKey branches visual.test.ts (287) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam - integration.test.ts (660) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking - editor.test.ts (37) Inclusive ranges without resetting the native selection anchor - v2.test.ts (298) Experimental v2 facade contract and lifecycle tests + integration.test.ts (662) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking + editor.test.ts (90) Host-coordinate boundaries without resetting the native selection anchor + 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) ``` @@ -168,7 +168,9 @@ 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. `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"]`. 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. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fb3123..5ea48a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### 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. +- 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. ### Fixed -- Visual `h`/`l` now selects through the cursor in both tested hosts, including backward motions and crossing the anchor, without resetting the anchor for subsequent word or line motions. +- 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 and poll boundedly for asynchronous form/permission completion, retaining the last server response in receipts. ## [0.18.1] — 2026-09-11 diff --git a/docs/opencode-v2-poc.md b/docs/opencode-v2-poc.md index d918adf..e96ab93 100644 --- a/docs/opencode-v2-poc.md +++ b/docs/opencode-v2-poc.md @@ -36,7 +36,7 @@ Build a tarball from this worktree with `npm pack --ignore-scripts --pack-destin Use a new artifact directory when repacking so the host does not reuse an older cache entry. The plugin's `package.json` already exports `./tui`; there is no v2-only package or runtime dependency. For local source, `just dev2` uses the root `tui.ts` shim required by v2's directory discovery. Use the tarball path for testing distributed-package behavior. -`experimentalV2Leader` defaults to `"ctrl+x"`, accepts a string or an array of strings, and can be set to `false` or `"none"` if the host leader is disabled. If the host leader is `space`, set **both** the host keybind and `experimentalV2Leader: "space"`. The v2 plugin cannot detect the host leader setting, so mismatches can swallow shortcuts or swallow printable input. The v2 disabled state/update-check timestamp live in the new plugin-scoped `settings` store and do not migrate v1 KV values. +`experimentalV2Leader` defaults to `"ctrl+x"`, accepts a string or an array of strings, and can be set to `false` or `"none"` if the host leader is disabled. If the host leader is `space`, set **both** the host keybind and `experimentalV2Leader: "space"`. The v2 plugin cannot detect the host leader setting, so mismatches can swallow shortcuts or swallow printable input. The v2 disabled state/update-check timestamp live in the new plugin-scoped `settings` store and do not migrate v1 KV values. Disabled state is **per activation**: another TUI's saved toggle takes effect on reload, not mid-activation. Local `/vim` toggles immediately update both the controller and the textual-form leader guard, then persist the setting. ## POC results (2026-09-30) @@ -50,7 +50,9 @@ The same tarball was exercised in real macOS tmux sessions on OpenCode **2.0.15* - The maintainer also reported successful manual smoke checks through both `just dev` and `just dev2`. - A repeatable installed-package exact-state harness now verifies visual selection both directions, physical-key setup, Unicode replacement, custom leader, autocomplete, v2 forms/permission rejection, disable/reload, and full tarball/cache identity. After review fixes, independent parent receipts on v1.18.33/v2.0.15 report 26/33 passing checks respectively, two explicitly named inherited known gaps each, zero failures; cold and warm loads both passed. Visual checkpoints assert offsets, ranges, selected text and focus, including mixed horizontal/word/line/vertical motions. Each checkpoint requires a fresh observer acknowledgement. Typecheck still reports the same eight pre-existing errors. -Latest parent receipts are `/private/tmp/vimcode-v2-hardening/review-fixed-v1/receipt.json` and `review-fixed-v2/receipt.json`. Both verify artifact SHA-256 `8e43ee3812ecf2baf325fcc97b4e73d6e49ea116ed719fb2e7e12dbab218f7f0`. Earlier exploratory evidence remains under `/tmp/vimcode-v2-poc-runtime/` and `/tmp/vimcode-v2-alternatives/`. +- Subsequent checked-review regression runs on the same pinned hosts passed 39 v1 / 48 v2 exact-state checks, cold and warm installation, with only the unchanged tab-offset and snapshot-redo known gaps. Independent parent runs confirmed those results at `/private/tmp/vimcode-pr82-fixes/parent-v1/receipt.json` and `parent-v2/receipt.json`, using artifact SHA-256 `5158aae9c59ee27bb6476d357618ea59a58e0603ca7c199facd389bb88e49530`. New cases cover empty buffers, EOF, EOL and backward EOL, read-only wide-character/tab endpoint controls, and a real v2 home-to-session editor switch. Horizontal endpoints now use host-coordinate range probes rather than JavaScript string lengths; visual ownership is captured on entry and re-anchored at the new editor's cursor on the next eligible key. Mixed word/line/vertical endpoint differences remain explicit. + +Earlier parent receipts are `/private/tmp/vimcode-v2-hardening/review-fixed-v1/receipt.json` and `review-fixed-v2/receipt.json`. Both verify artifact SHA-256 `8e43ee3812ecf2baf325fcc97b4e73d6e49ea116ed719fb2e7e12dbab218f7f0`. Earlier exploratory evidence remains under `/tmp/vimcode-v2-poc-runtime/` and `/tmp/vimcode-v2-alternatives/`. ## Before claiming support @@ -60,7 +62,7 @@ Earlier exact-buffer tests on v1.15.4, v1.18.33 and v2.0.15 found shared tab/CJK ## Repeatable host check -The optional runner needs Python 3.9+, npm, tmux and an explicit OpenCode binary. Use a **new empty output directory outside any checkout**, preferably a canonical OS-temp path (for example `/private/tmp` on macOS, not its `/tmp` symlink spelling). It refuses project OpenCode config ancestors, pins the binary version and runs the host with `env -i`, an isolated HOME/XDG set, a dedicated tmux socket, synthetic sessions, and clipboard stubs. It does not contact a model, approve a permission or read the system clipboard. It requires macOS, Linux or WSL; native Windows has not been tested. +The optional runner needs Python 3.9+, npm, tmux and an explicit OpenCode binary. Use a **new empty output directory outside any checkout**, preferably a canonical OS-temp path (for example `/private/tmp` on macOS, not its `/tmp` symlink spelling). It refuses project OpenCode config ancestors, pins the binary version and runs the host with `env -i`, an isolated HOME/XDG set, a dedicated tmux socket with an explicit empty config, synthetic sessions, and clipboard stubs. It does not contact a model, approve a permission or read the system clipboard. It requires macOS, Linux or WSL; native Windows has not been tested. ```sh just compat v1 /absolute/path/to/opencode-1.18.33 1.18.33 /private/tmp/vimcode-v1-fresh-run @@ -68,6 +70,6 @@ just compat v2 /absolute/path/to/opencode-2.0.15 2.0.15 /private/tmp/vimcode-v2- just compat-unit ``` -Each invocation packs the current worktree, tests cold and warm plugin loading independently, and checks the installed source/manifest byte-for-byte against the tarball. The output `receipt.json` records environment, artifact/version, exact editor text/cursor/selection traces and each check as `pass`, `known-gap` or `fail`. A nonce handshake prevents stale observer output from counting as a checkpoint. A cold failure makes the command fail even if the warm replay succeeds. Cleanup timeouts are recorded as failures without losing the final receipt; only the run's private tmux socket/session is targeted. The test-only fixture lives in `test/compat/fixture` and is excluded from the published package (`package.json` ships only `src/`). This is a manual host check, not part of fast `just check` CI. +Each invocation packs the current worktree, tests cold and warm plugin loading independently, and checks the installed source/manifest byte-for-byte against the tarball. The output `receipt.json` records environment, artifact/version, exact editor text/cursor/selection traces and each check as `pass`, `known-gap` or `fail`. A nonce handshake prevents stale observer output from counting as a checkpoint. Form-answer and permission-clear checks separately poll server state with a bounded deadline, retaining the last response in the receipt even on timeout. A cold failure makes the command fail even if the warm replay succeeds. Cleanup timeouts are recorded as failures without losing the final receipt; only the run's private tmux socket/session is targeted. The test-only fixture lives in `test/compat/fixture` and is excluded from the published package (`package.json` ships only `src/`). The live-host runner is manual, not part of fast `just check` CI. CI also runs `just compat-unit` for lightweight harness regressions; its real private-tmux config check is skipped when tmux is unavailable, while command-isolation checks always run. The POC demonstrates dual-host core editing, not complete v2 feature parity or a production-safe input API. diff --git a/src/editor.ts b/src/editor.ts index d4ea01d..06579f9 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -4,7 +4,11 @@ type VisualSelectionEditor = { cursorOffset: number; selectionBg?: RGBA; selectionFg?: RGBA; - editorView?: { setSelection(start: number, end: number, bg?: RGBA, fg?: RGBA): void }; + editBuffer?: { getTextRange(start: number, end: number): string }; + editorView?: { + setSelection(start: number, end: number, bg?: RGBA, fg?: RGBA): void; + resetSelection(): void; + }; requestRender?: () => void; }; @@ -12,9 +16,15 @@ type VisualSelectionEditor = { // The top-level setSelection() clears it, so a following word/line motion // would restart selection at the current cursor instead of the Vim anchor. export function selectVisualCharacterRange(editor: VisualSelectionEditor, anchor: number): void { - if (!editor.editorView?.setSelection) return; + if (!editor.editorView?.setSelection || !editor.editBuffer?.getTextRange) return; const start = Math.min(anchor, editor.cursorOffset); - const end = Math.max(anchor, editor.cursorOffset) + 1; - editor.editorView.setSelection(start, end, editor.selectionBg, editor.selectionFg); + const last = Math.max(anchor, editor.cursorOffset); + // Offsets are host display cells, not JavaScript string indices. A host + // range probe distinguishes a character from EOF/EOL without truncating + // wide characters or selecting a newline when moving onto a line boundary. + const character = editor.editBuffer.getTextRange(last, last + 1); + const end = character && character !== "\n" ? last + 1 : last; + if (start === end) editor.editorView.resetSelection(); + else editor.editorView.setSelection(start, end, editor.selectionBg, editor.selectionFg); editor.requestRender?.(); } diff --git a/src/index.ts b/src/index.ts index 8212327..19142c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,12 @@ const plugin = { // The host editor's undo system splits repeated commands into multiple // entries, so we save/restore the buffer ourselves. let undoSnapshots: Array<{ text: string; cursor: number }> = []; + let visualEditorOwner: unknown; + let disposed = false; + api.lifecycle?.onDispose?.(() => { + disposed = true; + visualEditorOwner = undefined; + }); const prompt = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", @@ -141,6 +147,14 @@ const plugin = { : undefined; const visualEditor = visualAnchor === undefined ? undefined : api.renderer?.currentFocusedEditor; setTimeout(() => { + if ( + disposed || + (visualEditor && + (api.ui?.dialog?.open || + api.renderer?.currentFocusedEditor !== visualEditor || + api.renderer?.currentFocusedRenderable !== visualEditor)) + ) + return; const dispatched = api.keymap.dispatchCommand(action.cmd); if ( dispatched?.ok && @@ -148,6 +162,8 @@ const plugin = { visualAnchor !== undefined && state.mode === "visual" && state.visualAnchor === visualAnchor && + visualEditorOwner === visualEditor && + !api.ui?.dialog?.open && api.renderer?.currentFocusedEditor === visualEditor && api.renderer?.currentFocusedRenderable === visualEditor ) { @@ -157,6 +173,7 @@ const plugin = { break; } case "mode": + visualEditorOwner = action.mode === "visual" ? api.renderer?.currentFocusedEditor : undefined; if (modeIndicator === "toast") { const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); api.ui?.toast?.({ @@ -394,6 +411,13 @@ const plugin = { } } + // Visual mode follows the active prompt, never an old editor's offset. + // Capture ownership on entry, then re-anchor only after overlay guards. + const editor = api.renderer?.currentFocusedEditor; + if (state.mode === "visual" && editor && editor !== visualEditorOwner) { + visualEditorOwner = editor; + state.visualAnchor = editor.cursorOffset; + } const handlerMode = state.mode; const result = state.mode === "insert" diff --git a/src/v2.ts b/src/v2.ts index 17c94da..62b2609 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -88,6 +88,9 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose const [settings, updateSettings] = context.storage.store("settings", { initial: { disabled: false, lastUpdateCheck: "" }, }); + // Match the shared controller's per-activation snapshot. Other TUI writes + // take effect on reload; only this activation's /vim toggle changes it now. + let effectiveDisabled = settings.disabled; const config = context.options.experimentalV2Leader; const leader = config === undefined @@ -154,7 +157,7 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose if (!editor || editor !== context.renderer.currentFocusedRenderable) return; // v2's leader token also matches inside textual forms. Insert its // character without running Vim commands against the form's input. - if (mode === "form" && !settings.disabled) { + if (mode === "form" && !effectiveDisabled) { const leader = findMatchingLeader(event, leaders); const character = leader && leaderChar(leader); if (character && editor.insertText) { @@ -218,12 +221,13 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose kv: { get: async (key: string) => key === "vimcode.disabled" - ? settings.disabled + ? effectiveDisabled : key === "lastUpdateCheck" ? settings.lastUpdateCheck : undefined, set: async (key: string, value: unknown) => { if (disposed) return; + if (key === "vimcode.disabled") effectiveDisabled = value === true; await updateSettings((draft) => { if (key === "vimcode.disabled") draft.disabled = value === true; if (key === "lastUpdateCheck") draft.lastUpdateCheck = String(value); diff --git a/test/compat/cases.py b/test/compat/cases.py index 58ebc64..11e4797 100644 --- a/test/compat/cases.py +++ b/test/compat/cases.py @@ -46,6 +46,32 @@ def run_core(ctx): send(ctx, "x") record(ctx, "visual-cross-anchor-delete", {"text": "ala beta gamma", "offset": 2, "selected": "", "selection": None}) + seed(ctx, "") + keys(ctx, "vl") + record(ctx, "visual-empty-no-placeholder", {"text": "", "offset": 0, "selected": "", "selection": None}) + send(ctx, "v") + for name, value, offset, motions, selected, start, end, remaining in ( + ("eof", "ab", 1, "vl", "b", 1, 2, "a"), + ("eol", "a\nb", 0, "vl", "a", 0, 1, "\nb"), + ("backward-eol", "a\nb", 1, "vh", "a", 0, 1, "\nb"), + ): + seed(ctx, value, offset) + keys(ctx, motions) + record(ctx, f"visual-{name}-selection", {"selected": selected, "selection": {"start": start, "end": end}}) + send(ctx, "x") + record(ctx, f"visual-{name}-delete", {"text": remaining, "offset": start, "selected": "", "selection": None}) + # Controls preserve PR82's wide endpoint behavior. v1 still has inherited + # wide deletion differences; these are not a claim of Unicode parity. + for name, glyph in (("cjk", "界"), ("emoji", "😀"), ("tab", "\t")): + seed(ctx, "a" + glyph + "b") + keys(ctx, "vl") + end = 3 if ctx["args"].host == "v2" else 2 + record(ctx, f"wide-{name}-selection-control", {"offset": 1, "selected": "a" + glyph, + "selection": {"start": 0, "end": end}}) + send(ctx, "x") + record(ctx, f"wide-{name}-delete-control", {"text": "b" if ctx["args"].host == "v2" else glyph + "b", + "offset": 0, "selected": "", "selection": None}) + # Native word/vertical endpoint semantics still differ between hosts. The # horizontal fix must preserve their original anchor, not change them. v2 = ctx["args"].host == "v2" @@ -96,9 +122,29 @@ def run_core(ctx): record(ctx, "tab-inner-word-offset", {"text": "a\t gamma"}, {"text": "ata gamma"}) +def completed_command(ctx, action, check, seconds=8): + # A renderer acknowledgement is not evidence of a completed server write. + # Persist every queried response, including the last one on timeout. + def query(): + result = command(ctx, action) + ctx["report"].setdefault("completionResponses", {})[action] = result + save(ctx) + return result + return wait_for(query, check, action + " completion", seconds) + + def run_v2_prompts(ctx): + seed(ctx, "alpha beta gamma", 4) + send(ctx, "v") + old_editor = snapshot(ctx)["editorId"] command(ctx, "session") - wait_for(lambda: snapshot(ctx), lambda value: value["route"].get("type") == "session", "synthetic session") + wait_for(lambda: snapshot(ctx), lambda value: value["route"].get("type") == "session" and + value["editorId"] != old_editor, "new synthetic session editor") + seed(ctx, "hello", preserve_mode=True) + send(ctx, "l") + record(ctx, "visual-editor-switch-selection", {"offset": 1, "selected": "he", "selection": {"start": 0, "end": 2}}) + send(ctx, "x") + record(ctx, "visual-editor-switch-delete", {"text": "llo", "offset": 0, "selected": "", "selection": None}) command(ctx, "form") wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "root form") for ch in "hello world": @@ -106,9 +152,8 @@ def run_v2_prompts(ctx): record(ctx, "root-form-space", {"text": "hello world", "mode": "form"}) send(ctx, "Enter", False) send(ctx, "Enter", False) - result = command(ctx, "formState") - if result["state"] != {"status": "answered", "answer": {"answer": "hello world"}}: - raise AssertionError(result) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "hello world"}}) command(ctx, "childForm") wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "child form") for ch in "child words": @@ -116,9 +161,8 @@ def run_v2_prompts(ctx): record(ctx, "child-form-space", {"text": "child words", "mode": "form"}) send(ctx, "Enter", False) send(ctx, "Enter", False) - result = command(ctx, "formState") - if result["state"] != {"status": "answered", "answer": {"answer": "child words"}}: - raise AssertionError(result) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "child words"}}) command(ctx, "permission") wait_for(lambda: capture(ctx), lambda pane: "Permission required" in pane, "pending permission") send(ctx, "Escape", False) @@ -128,8 +172,7 @@ def run_v2_prompts(ctx): send(ctx, ch) record(ctx, "child-permission-message", {"text": "no thanks"}) send(ctx, "Enter", False) - if command(ctx, "permissions") != []: - raise AssertionError("Pending permission was not rejected") + completed_command(ctx, "permissions", lambda result: result == []) def run_v2_reload(ctx): diff --git a/test/compat/driver.py b/test/compat/driver.py index 2a46480..5b1efc5 100644 --- a/test/compat/driver.py +++ b/test/compat/driver.py @@ -87,7 +87,7 @@ def new_context(args, output, tarball): def tmux(ctx, *parts): - command = ["tmux", "-L", ctx["socket"], *parts] + command = ["tmux", "-L", ctx["socket"], "-f", "/dev/null", *parts] return subprocess.run(command, text=True, capture_output=True, check=True, timeout=8).stdout @@ -270,8 +270,9 @@ def normal(ctx): value.get("cursorStyle", {}).get("style") == "block", "Vim normal cursor", 3) -def seed(ctx, text, offset=0): - normal(ctx) +def seed(ctx, text, offset=0, preserve_mode=False): + if not preserve_mode: + normal(ctx) uid = uuid.uuid4().hex atomic_json(ctx["request"], {"action": "seed", "id": uid, "text": text, "offset": offset}) state = wait_for(lambda: snapshot(ctx), lambda value: value.get("applied") == uid, "seeded editor", 5) diff --git a/test/compat/fixture/tui.ts b/test/compat/fixture/tui.ts index 28104c7..0904873 100644 --- a/test/compat/fixture/tui.ts +++ b/test/compat/fixture/tui.ts @@ -9,7 +9,7 @@ type Editor = { getSelection?: () => { start: number; end: number } | null; clearSelection?: () => boolean; setText?: (text: string) => void; - editBuffer?: { setText: (text: string) => void }; + editBuffer?: { setText: (text: string) => void; getTextRange?: (start: number, end: number) => string }; editorView?: { getSelection?: () => { start: number; end: number } | null; getSelectedText?: () => string; @@ -156,6 +156,7 @@ function observe( afterSeed, text: editor?.plainText ?? null, offset: editor?.cursorOffset ?? null, + nextCell: editor?.editBuffer?.getTextRange?.(editor.cursorOffset, editor.cursorOffset + 1) ?? null, cursor: cursor ? { logicalRow: cursor.logicalRow, logicalCol: cursor.logicalCol, offset: cursor.offset } : null, selection: editor?.getSelection?.() ?? editor?.editorView?.getSelection?.() ?? null, selected: editor?.editorView?.getSelectedText?.() ?? null, diff --git a/test/compat/test_driver.py b/test/compat/test_driver.py new file mode 100644 index 0000000..ff79ed4 --- /dev/null +++ b/test/compat/test_driver.py @@ -0,0 +1,73 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch +import uuid + +from cases import completed_command +from driver import tmux + + +def test_tmux_always_names_owned_socket_and_empty_config(): + with patch("driver.subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout="")) as run: + tmux({"socket": "owned-random-socket"}, "new-session", "-d", "-s", "owned-session") + assert run.call_args.args[0] == ["tmux", "-L", "owned-random-socket", "-f", "/dev/null", + "new-session", "-d", "-s", "owned-session"] + + +def test_private_tmux_ignores_hostile_parent_home_config(): + if not shutil.which("tmux"): + raise unittest.SkipTest("tmux not installed; command isolation still tested without tmux") + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + config = "set -g base-index 1\nset -g pane-base-index 1\nset -g destroy-unattached on\n" + (home / ".tmux.conf").write_text(config) + ctx = {"socket": "vimcode-unit-" + uuid.uuid4().hex, "session": "owned-test"} + with patch.dict(os.environ, {"HOME": directory, "XDG_CONFIG_HOME": directory}): + try: + tmux(ctx, "new-session", "-d", "-s", ctx["session"], "sleep 30") + assert tmux(ctx, "list-panes", "-t", ctx["session"], "-F", "#{window_index}.#{pane_index}").strip() == "0.0" + assert tmux(ctx, "show-options", "-gv", "destroy-unattached").strip() == "off" + tmux(ctx, "capture-pane", "-p", "-t", ctx["session"] + ":0.0") + finally: + tmux(ctx, "kill-server") # Only the randomly named server above. + assert (home / ".tmux.conf").read_text() == config + + +def test_server_completion_polls_past_first_pending_response(): + for action, pending, complete in ( + ("formState", {"state": {"status": "pending"}}, {"state": {"status": "answered"}}), + ("permissions", [{"id": "pending"}], []), + ): + with tempfile.TemporaryDirectory() as directory: + ctx = {"output": Path(directory), "report": {}} + with patch("cases.command", side_effect=[pending, complete]) as command: + assert completed_command(ctx, action, lambda result: result == complete) == complete + assert command.call_count == 2 + receipt = json.loads((Path(directory) / "receipt.json").read_text()) + assert receipt["completionResponses"][action] == complete + + +def test_permanent_server_pending_is_bounded_and_receipted(): + for action, pending in (("formState", {"state": {"status": "pending"}}), ("permissions", [{"id": "pending"}])): + with tempfile.TemporaryDirectory() as directory: + ctx = {"output": Path(directory), "report": {}} + with patch("cases.command", return_value=pending): + try: + completed_command(ctx, action, lambda _: False, seconds=0.01) + except RuntimeError as error: + assert action + " completion" in str(error) + assert "pending" in str(error) + else: + raise AssertionError("Permanently pending server state was accepted") + receipt = json.loads((Path(directory) / "receipt.json").read_text()) + assert receipt["completionResponses"][action] == pending + + +def load_tests(_loader, _tests, _pattern): + functions = [value for name, value in globals().items() if name.startswith("test_") and callable(value)] + return unittest.TestSuite(unittest.FunctionTestCase(function) for function in functions) diff --git a/test/editor.test.ts b/test/editor.test.ts index 621a2e5..48f922f 100644 --- a/test/editor.test.ts +++ b/test/editor.test.ts @@ -1,32 +1,85 @@ import { describe, expect, it } from "bun:test"; import { selectVisualCharacterRange } from "../src/editor"; +function editor(cursorOffset: number, character = "c") { + const calls: number[][] = []; + const probes: number[][] = []; + let renders = 0; + let resets = 0; + let clearedAnchor = false; + return { + cursorOffset, + calls, + probes, + get renders() { + return renders; + }, + get resets() { + return resets; + }, + get clearedAnchor() { + return clearedAnchor; + }, + setSelection() { + clearedAnchor = true; + }, + editBuffer: { + getTextRange: (start: number, end: number) => { + probes.push([start, end]); + return character; + }, + }, + editorView: { + setSelection: (start: number, end: number) => calls.push([start, end]), + resetSelection: () => resets++, + }, + requestRender: () => renders++, + }; +} + describe("selectVisualCharacterRange", () => { it("includes the cursor without clearing the renderer's native selection anchor", () => { - const calls: number[][] = []; - let renders = 0; - let clearedAnchor = false; - const editor = { - cursorOffset: 2, - setSelection() { - clearedAnchor = true; - }, - editorView: { setSelection: (start: number, end: number) => calls.push([start, end]) }, - requestRender: () => renders++, - }; - selectVisualCharacterRange(editor, 0); - expect(calls).toEqual([[0, 3]]); - expect(clearedAnchor).toBe(false); - expect(renders).toBe(1); + const mock = editor(2); + selectVisualCharacterRange(mock, 0); + expect(mock.calls).toEqual([[0, 3]]); + expect(mock.probes).toEqual([[2, 3]]); + expect(mock.clearedAnchor).toBe(false); + expect(mock.renders).toBe(1); }); it("keeps the original anchor when moving backward", () => { - const calls: number[][] = []; - selectVisualCharacterRange( - { cursorOffset: 2, editorView: { setSelection: (start, end) => calls.push([start, end]) } }, - 4, - ); - expect(calls).toEqual([[2, 5]]); + const mock = editor(2); + selectVisualCharacterRange(mock, 4); + expect(mock.calls).toEqual([[2, 5]]); + }); + + for (const character of ["", "\n"]) { + it(`does not expand across ${character ? "EOL" : "EOF"}, including backward selection`, () => { + for (const [cursor, anchor] of [ + [2, 1], + [1, 2], + ]) { + const mock = editor(cursor, character); + selectVisualCharacterRange(mock, anchor); + expect(mock.calls).toEqual([[1, 2]]); + expect(mock.clearedAnchor).toBe(false); + } + }); + } + + it("resets the lower selection on empty buffers without selecting placeholder text", () => { + const mock = editor(0, ""); + selectVisualCharacterRange(mock, 0); + expect(mock.calls).toEqual([]); + expect(mock.resets).toBe(1); + expect(mock.clearedAnchor).toBe(false); + }); + + it("uses host display-cell offsets even when they exceed JavaScript string length", () => { + const mock = editor(5, "界"); + selectVisualCharacterRange(mock, 4); + expect(mock.probes).toEqual([[5, 6]]); + expect(mock.calls).toEqual([[4, 6]]); }); it("leaves unsupported editors alone instead of using an anchor-clearing fallback", () => { diff --git a/test/integration.test.ts b/test/integration.test.ts index 7ffe85e..73295e2 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -430,7 +430,9 @@ describe("visual character selection dispatch guard", () => { cursorOffset: 2, visualCursor: { logicalRow: 0 }, cursorStyle: { style: "line", blinking: true }, + editBuffer: { getTextRange: () => "l" }, editorView: { + resetSelection: () => {}, setSelection(start: number, end: number) { selections.push([start, end]); }, diff --git a/test/v2.test.ts b/test/v2.test.ts index b1e8e11..fd436d4 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -25,7 +25,14 @@ function host(options: Record = {}, saved = { disabled: false, let focused = true; let slotRender: (() => null) | undefined; let removedSlot = false; + const selections: Array<[number, number]> = []; const editor = { + editBuffer: { getTextRange: () => "l" }, + editorView: { + resetSelection: () => {}, + setSelection: (start: number, end: number) => selections.push([start, end]), + }, + setSelectionInclusive: (start: number, end: number) => selections.push([start, end + 1]), plainText: "hello", cursorOffset: 2, visualCursor: { logicalRow: 0 }, @@ -37,7 +44,7 @@ function host(options: Record = {}, saved = { disabled: false, const renderer = { currentFocusedEditor: editor, get currentFocusedRenderable() { - return focused ? editor : undefined; + return focused ? renderer.currentFocusedEditor : undefined; }, keyInput: { prependListener(_name: "keypress", listener: (event: RawPressEvent) => void) { @@ -56,6 +63,9 @@ function host(options: Record = {}, saved = { disabled: false, mode: { current: () => mode }, dispatch: (id) => { dispatched.push(id); + const current = renderer.currentFocusedEditor; + if (id === "input.select.right") current.cursorOffset++; + if (id === "input.select.left") current.cursorOffset = Math.max(0, current.cursorOffset - 1); }, layer: (input) => { commands.push(...input().commands); @@ -128,6 +138,10 @@ function host(options: Record = {}, saved = { disabled: false, press, emit, editor, + selections, + setEditor: (value: typeof editor) => { + renderer.currentFocusedEditor = value; + }, commands, dispatched, toasts, @@ -222,6 +236,63 @@ describe("OpenCode v2 POC facade", () => { expect(disabled.press("space").stopped).toBe(false); }); + for (const initial of [false, true]) { + it(`keeps controller and form disabled policy local after saved ${initial} -> ${!initial} reconciliation`, async () => { + const mock = host( + { updateCheck: false, startMode: "normal", experimentalV2Leader: "space" }, + { disabled: initial, lastUpdateCheck: "" }, + ); + await start(mock); + mock.saved.disabled = !initial; // Another TUI reconciles the live store. + expect(mock.press("z").stopped).toBe(!initial); + mock.setMode("form"); + expect(mock.press("space").stopped).toBe(!initial); + await mock.commands.find((command) => command.id === "vimcode.vim")?.run(); + expect(mock.saved.disabled).toBe(!initial); + expect(mock.press("space").stopped).toBe(initial); + mock.setMode("base"); + expect(mock.press("z").stopped).toBe(initial); + }); + } + + it("re-anchors visual on a different editor before its first motion, counts and text objects", async () => { + const mock = host({ updateCheck: false, startMode: "normal" }); + await start(mock); + mock.editor.cursorOffset = 4; + mock.press("v"); // Ownership must be captured here, before any motion. + const second = { ...mock.editor, cursorOffset: 0 }; + mock.setEditor(second); + mock.press("l"); + await Bun.sleep(20); + expect(mock.selections).toEqual([[0, 2]]); + expect(mock.editor.cursorOffset).toBe(4); + mock.press("2"); + mock.press("l"); + await Bun.sleep(20); + expect(mock.selections.at(-1)).toEqual([0, 4]); + mock.press("h"); + await Bun.sleep(20); + expect(mock.selections.at(-1)).toEqual([0, 3]); + mock.press("i"); + mock.press("w"); + expect(mock.selections.at(-1)).toEqual([0, 5]); + }); + + it("does not normalize or dispatch a queued visual motion after focus loss, overlay or disposal", async () => { + for (const change of ["editor", "overlay", "dispose"]) { + const mock = host({ updateCheck: false, startMode: "normal" }); + const dispose = await start(mock); + mock.press("v"); + mock.press("l"); + if (change === "editor") mock.setEditor({ ...mock.editor, cursorOffset: 0 }); + if (change === "overlay") mock.setMode("form"); + if (change === "dispose") dispose(); + await Bun.sleep(20); + expect(mock.selections).toEqual([]); + expect(mock.dispatched).not.toContain("input.select.right"); + } + }); + it("uses host autocomplete state for Enter/Escape in insert mode without intercepting typed keys", async () => { const mock = host({ updateCheck: false }); await start(mock); From d8050f765b6c2c4e7fdc700d8345c1c5752644cb Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 1 Oct 2026 22:37:14 +0300 Subject: [PATCH 3/4] fix: address v2 compatibility review findings --- AGENTS.md | 4 +-- CHANGELOG.md | 7 +++- README.md | 71 +++++++++++++++++++++++++++++++++++--- src/editor.ts | 12 +++---- test/compat/run.py | 2 +- test/compat/test_driver.py | 41 ++++++++++++++++++++++ test/editor.test.ts | 28 +++++++++++++++ 7 files changed, 150 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4db9867..af923f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ index.ts (474 lines) Dual v1 tui/v2 setup entry: intercept registration, action application - editor.ts (30 lines) Host-coordinate horizontal selection bounds, preserving native anchor + 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. @@ -87,7 +87,7 @@ test/ visual.test.ts (287) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam integration.test.ts (662) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking - editor.test.ts (90) Host-coordinate boundaries without resetting the native selection anchor + 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea48a2..e3c1be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - 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 installation, options, and custom leader settings separately for OpenCode v1 and experimental v2. + ### 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 and poll boundedly for asynchronous form/permission completion, retaining the last server response in receipts. +- 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 diff --git a/README.md b/README.md index 096e68c..4152360 100644 --- a/README.md +++ b/README.md @@ -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 @@ -34,9 +38,36 @@ 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 it, build a tarball from a checkout containing this branch's v2 changes: + +```sh +mkdir -p /absolute/path/to/artifacts +npm pack --ignore-scripts --pack-destination /absolute/path/to/artifacts +``` + +Add the tarball to your global `cli.json`, using its absolute path: + +```json +{ + "plugins": [ + { + "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz" + } + ] +} +``` + +The generated filename still uses the package version `0.18.1`, but this tarball contains the unreleased v2 code. Use a new artifact directory when rebuilding so OpenCode does not reuse an older cached package. + +This path was tested on OpenCode 2.0.15 on macOS. See [the v2 POC instructions](docs/opencode-v2-poc.md) for local development 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 { @@ -44,11 +75,29 @@ To pass options, use the tuple form in `tui.json`: } ``` +### OpenCode v2 options + +Put `options` alongside `package` in `cli.json`: + +```json +{ + "plugins": [ + { + "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz", + "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 @@ -68,13 +117,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 (`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 { @@ -84,7 +133,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@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz", + "options": { "experimentalV2Leader": "space" } + } + ] +} +``` + +If they do not match, vimcode can swallow leader shortcuts or printable input. ### Platform notes diff --git a/src/editor.ts b/src/editor.ts index 06579f9..30456ce 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,12 +1,10 @@ -import type { RGBA } from "@opentui/core"; - -type VisualSelectionEditor = { +type VisualSelectionEditor = { cursorOffset: number; - selectionBg?: RGBA; - selectionFg?: RGBA; + selectionBg?: Color; + selectionFg?: Color; editBuffer?: { getTextRange(start: number, end: number): string }; editorView?: { - setSelection(start: number, end: number, bg?: RGBA, fg?: RGBA): void; + setSelection(start: number, end: number, bg?: Color, fg?: Color): void; resetSelection(): void; }; requestRender?: () => void; @@ -15,7 +13,7 @@ type VisualSelectionEditor = { // Normalize the visible range without clearing the renderer's native anchor. // The top-level setSelection() clears it, so a following word/line motion // would restart selection at the current cursor instead of the Vim anchor. -export function selectVisualCharacterRange(editor: VisualSelectionEditor, anchor: number): void { +export function selectVisualCharacterRange(editor: VisualSelectionEditor, anchor: number): void { if (!editor.editorView?.setSelection || !editor.editBuffer?.getTextRange) return; const start = Math.min(anchor, editor.cursorOffset); const last = Math.max(anchor, editor.cursorOffset); diff --git a/test/compat/run.py b/test/compat/run.py index 1b1aa5d..c58c73f 100644 --- a/test/compat/run.py +++ b/test/compat/run.py @@ -55,7 +55,7 @@ def main(): output = args.output.resolve() for ancestor in (output, *output.parents): - if (ancestor / ".git").exists() or any((ancestor / name).exists() for name in (".opencode", "opencode.json", "opencode.jsonc", "tui.json", "cli.json")): + if (ancestor / ".git").exists() or any((ancestor / name).exists() for name in (".opencode", "opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc", "cli.json", "cli.jsonc")): parser.error(f"--output must be outside a checkout and project OpenCode config: {ancestor}") if output.exists() and any(output.iterdir()): parser.error(f"--output must be new or empty: {output}") diff --git a/test/compat/test_driver.py b/test/compat/test_driver.py index ff79ed4..af8ac55 100644 --- a/test/compat/test_driver.py +++ b/test/compat/test_driver.py @@ -1,8 +1,11 @@ +import io +from itertools import product import json import os from pathlib import Path import shutil import subprocess +import sys import tempfile import unittest from unittest.mock import patch @@ -10,6 +13,44 @@ from cases import completed_command from driver import tmux +from run import main + + +def test_project_config_ancestors_are_rejected_before_packaging_or_host_launch(): + names = ("opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc", "cli.json", "cli.jsonc") + hosts = (("v1", "1.18.33"), ("v2", "2.0.15")) + for name, (host, version), symlinked in product(names, hosts, (False, True)): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + parent = root / "project" + parent.mkdir() + (parent / name).write_text("{}\n") + output = parent / "output" + output.mkdir() + supplied = output + if symlinked: + supplied = root / "output-link" + supplied.symlink_to(output, target_is_directory=True) + case = f"{host}, {name}, symlinked={symlinked}" + argv = ["run.py", "--host", host, "--binary", sys.executable, + "--expect-version", version, "--output", str(supplied)] + stderr = io.StringIO() + with patch("run.sys.argv", argv), patch("run.sys.platform", "linux"), \ + patch("run.sys.stderr", stderr), patch("run.shutil.which", return_value="/usr/bin/mock"), \ + patch("run.subprocess.check_output", return_value=version), \ + patch("run.subprocess.run", side_effect=AssertionError(f"Packaged under {case}")) as pack, \ + patch("run.start") as launch: + try: + main() + except SystemExit as error: + assert error.code == 2, case + else: + raise AssertionError(f"Accepted project config ancestor: {case}") + assert "--output must be outside a checkout and project OpenCode config" in stderr.getvalue(), case + assert str(parent) in stderr.getvalue(), case + pack.assert_not_called() + launch.assert_not_called() + assert list(output.iterdir()) == [], case def test_tmux_always_names_owned_socket_and_empty_config(): diff --git a/test/editor.test.ts b/test/editor.test.ts index 48f922f..1083b21 100644 --- a/test/editor.test.ts +++ b/test/editor.test.ts @@ -47,6 +47,34 @@ describe("selectVisualCharacterRange", () => { expect(mock.renders).toBe(1); }); + const background = { name: "background" }; + const foreground = { name: "foreground" }; + for (const [name, bg, fg] of [ + ["both", background, foreground], + ["background only", background, undefined], + ["foreground only", undefined, foreground], + ["neither", undefined, undefined], + ] as const) { + it(`forwards host-owned selection colors unchanged (${name})`, () => { + let colors: Array = []; + const mock = { + ...editor(2), + ...(bg ? { selectionBg: bg } : {}), + ...(fg ? { selectionFg: fg } : {}), + editorView: { + resetSelection: () => {}, + setSelection(_start: number, _end: number, selectedBg?: typeof background, selectedFg?: typeof foreground) { + colors = [selectedBg, selectedFg]; + }, + }, + }; + selectVisualCharacterRange(mock, 0); + expect(colors).toHaveLength(2); + expect(colors[0]).toBe(bg); + expect(colors[1]).toBe(fg); + }); + } + it("keeps the original anchor when moving backward", () => { const mock = editor(2); selectVisualCharacterRange(mock, 4); From 7d791d2b2066f8329d24b3d87f80fbcf0bd8eae6 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 1 Oct 2026 23:33:44 +0300 Subject: [PATCH 4/4] docs: clarify v1 and v2 Git installation --- CHANGELOG.md | 3 ++- CONTRIBUTING.md | 30 ++++++++++++++++++++++-------- README.md | 21 ++++++--------------- docs/opencode-v2-poc.md | 6 +++++- 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c1be1..f77d6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Changed -- Document installation, options, and custom leader settings separately for OpenCode v1 and experimental v2. +- 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a97cf10..927e207 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,9 @@ Running `opencode` directly in this directory won't load the plugin. `just dev` ## 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. @@ -39,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 @@ -68,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: `. 10. After CI passes, squash-merge the PR. @@ -77,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. diff --git a/README.md b/README.md index 4152360..0f81118 100644 --- a/README.md +++ b/README.md @@ -40,28 +40,19 @@ 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 it, build a tarball from a checkout containing this branch's v2 changes: - -```sh -mkdir -p /absolute/path/to/artifacts -npm pack --ignore-scripts --pack-destination /absolute/path/to/artifacts -``` - -Add the tarball to your global `cli.json`, using its absolute path: +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@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz" + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb" } ] } ``` -The generated filename still uses the package version `0.18.1`, but this tarball contains the unreleased v2 code. Use a new artifact directory when rebuilding so OpenCode does not reuse an older cached package. - -This path was tested on OpenCode 2.0.15 on macOS. See [the v2 POC instructions](docs/opencode-v2-poc.md) for local development and compatibility limits. +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 @@ -83,7 +74,7 @@ Put `options` alongside `package` in `cli.json`: { "plugins": [ { - "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz", + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb", "options": { "updateCheck": false } } ] @@ -140,7 +131,7 @@ On **v2**, set both the host keybind and the plugin option in `cli.json`: "keybinds": { "leader": "space" }, "plugins": [ { - "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.18.1.tgz", + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#d8050f765b6c2c4e7fdc700d8345c1c5752644cb", "options": { "experimentalV2Leader": "space" } } ] @@ -270,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 diff --git a/docs/opencode-v2-poc.md b/docs/opencode-v2-poc.md index e96ab93..63dbbbe 100644 --- a/docs/opencode-v2-poc.md +++ b/docs/opencode-v2-poc.md @@ -21,7 +21,9 @@ The root `tui.ts` re-exports `src/index.ts` for v2's local-directory loader. Thi The v2 public `keymap` has no key intercept or configured-leader lookup. The POC uses `context.renderer.keyInput.prependListener("keypress", ...)` to intercept prompt keys **before** the host keymap. It calls both `preventDefault()` and `stopPropagation()` on consumed keys. This raw OpenTUI ordering is not a documented OpenCode plugin guarantee; do not rely on it as production compatibility without testing against installed packages and future host releases. -Build a tarball from this worktree with `npm pack --ignore-scripts --pack-destination /absolute/path/to/artifacts`. With a separate v2 installation, configure that artifact in the global `cli.json` (not v1's `tui.json`): +The [README](../README.md#opencode-v2-experimental) shows the tested Git install pinned to commit `d8050f765b6c2c4e7fdc700d8345c1c5752644cb`. Use the global `cli.json` on v2, not v1's `tui.json`. + +To test local changes instead, create a fresh artifact directory and build a tarball with `npm pack --ignore-scripts --pack-destination /absolute/path/to/artifacts`. Configure that artifact in `cli.json`: ```json { @@ -52,6 +54,8 @@ The same tarball was exercised in real macOS tmux sessions on OpenCode **2.0.15* - Subsequent checked-review regression runs on the same pinned hosts passed 39 v1 / 48 v2 exact-state checks, cold and warm installation, with only the unchanged tab-offset and snapshot-redo known gaps. Independent parent runs confirmed those results at `/private/tmp/vimcode-pr82-fixes/parent-v1/receipt.json` and `parent-v2/receipt.json`, using artifact SHA-256 `5158aae9c59ee27bb6476d357618ea59a58e0603ca7c199facd389bb88e49530`. New cases cover empty buffers, EOF, EOL and backward EOL, read-only wide-character/tab endpoint controls, and a real v2 home-to-session editor switch. Horizontal endpoints now use host-coordinate range probes rather than JavaScript string lengths; visual ownership is captured on entry and re-anchored at the new editor's cursor on the next eligible key. Mixed word/line/vertical endpoint differences remain explicit. +- Pinned Git installs at `d8050f765b6c2c4e7fdc700d8345c1c5752644cb` also passed cold and warm loading on v1.18.33/v2.0.15, with 39/48 exact-state checks and the same two known gaps. Both verified the installed source and manifest byte-for-byte against a local package reference. The existing harness was reused through a temporary configure wrapper that replaced only the install spec; the reference tarball was not the installed package. Receipts are `/private/tmp/vimcode-pr82-git-d8050f7-v1/receipt.json` and `/private/tmp/vimcode-pr82-git-d8050f7-v2/receipt.json`; the wrapper is `/private/tmp/vimcode-pr82-git-install-check.py`. + Earlier parent receipts are `/private/tmp/vimcode-v2-hardening/review-fixed-v1/receipt.json` and `review-fixed-v2/receipt.json`. Both verify artifact SHA-256 `8e43ee3812ecf2baf325fcc97b4e73d6e49ea116ed719fb2e7e12dbab218f7f0`. Earlier exploratory evidence remains under `/tmp/vimcode-v2-poc-runtime/` and `/tmp/vimcode-v2-alternatives/`. ## Before claiming support