diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..4d72232 --- /dev/null +++ b/.env.template @@ -0,0 +1,6 @@ +# Optional local native binary overrides for `just test-e2e`. +# Leave commented to infer or privately install the pinned hosts. +# V1 must be OpenCode 1.18.34; V2 must be OpenCode 2.0.15. + +# VIMCODE_E2E_V1_BIN=/absolute/path/to/opencode-v1 +# VIMCODE_E2E_V2_BIN=/absolute/path/to/opencode-v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5799b9d..67f819d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,12 +2,181 @@ name: ci on: pull_request: branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.2' - 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 + + test-int: + name: Harness integration tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.2' + - uses: extractions/setup-just@v2 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: just install + - name: Install process inspection tools + run: sudo apt-get update && sudo apt-get install -y procps + - name: Harness unit and integration checks + shell: bash + run: just test-int 2>&1 | tee "$RUNNER_TEMP/test-int.log" + - name: Upload harness failure log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-int-failure + path: ${{ runner.temp }}/test-int.log + if-no-files-found: warn + retention-days: 7 + + test-e2e: + name: E2E tests (${{ matrix.host }}) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - host: v1 + version: '1.18.34' + - host: v2 + version: '2.0.15' + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.2' + - uses: extractions/setup-just@v2 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: just install + - name: Install process and archive tools + run: sudo apt-get update && sudo apt-get install -y procps tar curl + - name: Install and verify pinned native host + shell: bash + env: + HOST: ${{ matrix.host }} + VERSION: ${{ matrix.version }} + run: | + prefix="$RUNNER_TEMP/opencode-$HOST" + mkdir -p "$prefix/native" + if [[ "$HOST" == v1 ]]; then + curl --fail --location --retry 3 --output "$prefix/archive.tar.gz" \ + "https://github.com/anomalyco/opencode/releases/download/v$VERSION/opencode-linux-x64-baseline.tar.gz" + echo "24b0d458d21ef548b2752166303defcf7f4945b049fb4876ab78dfaf86d81b27 $prefix/archive.tar.gz" | sha256sum --check + tar -xzf "$prefix/archive.tar.gz" -C "$prefix/native" + else + npm install --prefix "$prefix/native" --no-save --ignore-scripts \ + "@opencode/cli-linux-x64-baseline@$VERSION" + node - "$prefix/native/node_modules/@opencode/cli-linux-x64-baseline/package.json" "$VERSION" <<'NODE' + const fs = require('node:fs'); + const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + if (manifest.name !== '@opencode/cli-linux-x64-baseline' || manifest.version !== process.argv[3]) { + throw new Error('Unexpected native host package identity'); + } + NODE + fi + mapfile -t binaries < <(find "$prefix/native" -type f -name opencode -perm /111) + [[ "${#binaries[@]}" == 1 ]] || { echo 'Expected one native host executable'; exit 1; } + binary="$(realpath "${binaries[0]}")" + actual="$("$binary" --version)" + actual="${actual#opencode v}" + [[ "$actual" == "$VERSION" ]] || { echo "Host version mismatch: $actual"; exit 1; } + echo "OPENCODE_E2E_BIN=$binary" >> "$GITHUB_ENV" + - name: Cache-installed plugin E2E + shell: bash + env: + HOST: ${{ matrix.host }} + VERSION: ${{ matrix.version }} + run: | + just test-e2e "$HOST" "$OPENCODE_E2E_BIN" "$VERSION" \ + "$RUNNER_TEMP/vimcode-e2e-$HOST" 2>&1 | tee "$RUNNER_TEMP/terminal-control-e2e-$HOST.log" + - name: Verify no-plugin negative control + shell: bash + env: + HOST: ${{ matrix.host }} + VERSION: ${{ matrix.version }} + run: | + set +e + VIMCODE_E2E_NO_PLUGIN=1 just test-e2e "$HOST" "$OPENCODE_E2E_BIN" "$VERSION" \ + "$RUNNER_TEMP/vimcode-negative-$HOST" 2>&1 | tee "$RUNNER_TEMP/terminal-control-negative-$HOST.log" + status=${PIPESTATUS[0]} + set -e + [[ "$status" == 1 ]] || { echo "Expected negative-control exit 1, got $status"; exit 1; } + node - "$RUNNER_TEMP/vimcode-negative-$HOST/receipt.json" "$HOST" "$VERSION" <<'NODE' + const assert = require('node:assert/strict'); + const receipt = require(process.argv[2]); + assert.equal(receipt.host, process.argv[3]); + assert.equal(receipt.version, process.argv[4]); + assert.equal(receipt.negativeControl, true); + assert.equal(receipt.providersDisabled, true); + assert.deepEqual(receipt.providerObservations, { + 'word-motion': 'Build · No provider selected Connect a provider' + }); + assert.deepEqual(receipt.cleanupErrors, []); + const failures = receipt.checks.filter(check => check.status === 'fail'); + assert.deepEqual(failures.map(check => check.name), ['word-home', 'word-motion']); + assert.deepEqual(failures[0].actual.lines, ['one two three0']); + NODE + - name: Upload synthetic E2E failure evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: terminal-control-e2e-${{ matrix.host }}-failure + path: | + ${{ runner.temp }}/terminal-control-e2e-${{ matrix.host }}.log + ${{ runner.temp }}/terminal-control-negative-${{ matrix.host }}.log + ${{ runner.temp }}/vimcode-negative-${{ matrix.host }}/receipt.json + ${{ runner.temp }}/vimcode-negative-${{ matrix.host }}/word-motion/*.txt + ${{ runner.temp }}/vimcode-negative-${{ matrix.host }}/word-motion/failure.json + ${{ runner.temp }}/vimcode-negative-${{ matrix.host }}/word-motion/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/receipt.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/cold-load/*.txt + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/cold-load/failure.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/cold-load/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/word-motion/*.txt + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/word-motion/failure.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/word-motion/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/quote-change/*.txt + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/quote-change/failure.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/quote-change/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/multiline-undo/*.txt + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/multiline-undo/failure.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/multiline-undo/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/answer-paste/*.txt + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/answer-paste/failure.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/answer-paste/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/*/failure-status.json + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index ad85280..1ad8ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ node_modules/ dist/ +.dev2/ .opencode/ .todo/ .assets/ package-lock.json bun.lock +.env +.env.* +!.env.template diff --git a/AGENTS.md b/AGENTS.md index 44a5edb..faf61e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,9 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on it, understand the plugin system: **References (read these, don't guess):** +- V2 migration: https://opencode.ai/v2/docs/migrate-v1/ +- V2 CLI plugins: https://opencode.ai/v2/docs/build/plugins/cli +- V2 integration lives in `src/v2.ts`; keep the upstream facade instead of rewriting the controller. The V1 references and gotchas below apply to the retained V1 entrypoint. - Official plugin docs: https://opencode.ai/docs/plugins/ - TUI plugin spec: https://github.com/sst/opencode/blob/dev/packages/opencode/specs/tui-plugins.md - Plugin types: `@opencode-ai/plugin/tui` exports `TuiPluginModule`, `TuiPluginApi` @@ -14,15 +17,17 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i `keymap` (register layers, intercepts, dispatch commands), `slots` (register UI into named slots), `ui` (toasts, dialogs), `theme` (colors), `prompt` (read/write prompt text), `state` (session, config), `client` (SDK), `lifecycle` (disposal), `kv` (persistent storage), `route` (custom screens). **Gotchas we hit during development:** -- TUI plugins go in `tui.json`, not `opencode.json`. The config field is `"plugin"`. +- V2 CLI-only plugins go in global `~/.config/opencode/cli.json` under `"plugins"` with `{ "package": "...", "options": {} }` entries. V1 uses `tui.json` and `"plugin"`. +- V2 cannot auto-detect custom leaders: `experimentalV2Leader` must match the host. Pending `f`/`F` targets bypass normal-mode leader handling so a printable leader can be found. - The plugin `package.json` needs `exports: { "./tui": "./src/index.ts" }` — the loader checks `./tui`, not `.`. - **`key:before` is NOT a valid intercept type.** The keymap only supports `"key"`, `"key:after"`, and `"raw"`. Passing `"key:before"` silently registers a raw terminal sequence handler that crashes on key events. - `dispatchCommand()` from inside a `key` intercept doesn't work for cursor movement. Wrap in `setTimeout(..., 0)` to break out of the intercept stack. - `registerLayer` with `activeWhen` using SolidJS signals requires `reactiveMatcherFromSignal` from `@opentui/keymap/solid`. Plain `() => signal()` doesn't trigger re-evaluation. We chose intercepts instead of layers to avoid this. - **Leader key is handled entirely within the keymap's `dispatchLayers()`.** There is no separate `useKeyboard` handler for it. `registerTimedLeader` registers a token; `dispatchLayers()` matches it; `getPendingSequence()` exposes the state. Calling `ctx.consume()` in a `key` intercept sets `event.propagationStopped`, which the keymap checks after each intercept — if set, it skips `dispatchLayers()` entirely. In insert mode, printable leaders are consumed and inserted as text; non-printable leaders (ctrl+x, etc.) are not consumed, so they fall through to `dispatchLayers()` and trigger OpenCode's leader bindings. - **`api.tuiConfig.keybinds`** gives access to OpenCode's resolved keybind config. `api.tuiConfig.keybinds.get("leader")?.[0]?.key` returns the configured leader key. Used by `resolveLeader()` to auto-detect the leader without requiring a separate plugin option. -- **SolidJS imports do NOT work in git-installed plugins.** The host's `ensureRuntimePluginSupport` intercepts `solid-js` and `@opentui/solid` imports, but `@opentui/solid/jsx-dev-runtime` (generated by the JSX transform) doesn't resolve from the package cache. Declaring them as peer deps also fails — Bun installs local `.d.ts` stubs that shadow the host's runtime modules. Until OpenCode fixes this, avoid JSX and `solid-js` imports in distributed plugins. Use `api.ui.toast()` for mode feedback instead of slot indicators. -- **Do NOT add `solid-js`, `@opentui/solid`, or `@opentui/core` as dependencies or peerDependencies.** If they're in `package.json`, Bun installs them into the plugin's `node_modules/`, and the local `.d.ts` stubs shadow the host's runtime module intercepts. The host provides these at runtime via `ensureRuntimePluginSupport`. Keep them only in `devDependencies` (via `@opencode-ai/plugin` which pulls them in for type-checking). +- **SolidJS/JSX still does not work in cache-installed plugins.** Last reproduced on 2026-09-08 with OpenCode 1.18.21 using an npm-source tarball. A plain `.ts` entry and its TUI hook loaded, but importing a `.tsx` module with `/** @jsxImportSource @opentui/solid */` failed with `Cannot find module '@opentui/solid/jsx-dev-runtime'`. The Solid transform excludes files under `node_modules`; the runtime prescan therefore cannot see the JSX-generated import before Bun resolves it. OpenCode 1.18.25 has identical relevant runtime code and also pins OpenTUI 0.4.5. OpenTUI 0.5.9 retains the exclusion. Until upstream changes this path, avoid JSX and `solid-js` imports in distributed plugins. Use `api.ui.toast()` for mode feedback instead of slot indicators. See [#3](https://github.com/oribarilan/vimcode/issues/3). +- **Do NOT add `solid-js`, `@opentui/solid`, or `@opentui/core` as dependencies or peerDependencies.** If they're in `package.json`, Bun installs them into the plugin's `node_modules/`, and the local `.d.ts` stubs shadow the host's runtime module intercepts. The host provides these at runtime via `ensureRuntimePluginSupport`. Keep host UI packages dev-only. Optional peers of `@opencode-ai/plugin` are not installed by a clean Bun install; declare test dependencies explicitly. `@opentui/keymap` is pinned in devDependencies for headless keymap tests. +- **Test distributed plugin behavior through the package cache.** `dev-tui.json` uses `"plugin": ["."]`, which loads from the working tree and does not reproduce cache-only module resolution failures. Use an npm-source tarball spec such as `name@file:/absolute/path/package.tgz` or the real `git+https://...#ref` install form, and clear only that package's cache entry before retesting. ### Editor widget API @@ -57,14 +62,45 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (395 lines) Plugin entry: intercept registration, action application - vim.ts (827 lines) Pure vim engine: state, handlers, command tables, types + index.ts (707 lines) Dual v1 tui/v2 setup entry: intercepts, answer ownership, action application + editor.ts (28 lines) Host-coordinate horizontal selection bounds, preserving native anchor + editing.ts (28 lines) Editor-local Vim contexts and shared ANSWER focus trait check + v2.ts (250 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 (60 lines) Action union, VimState, Mode, Operator, Pending (including find + operator), FindDirection, Range, KeyEvent, HandlerResult, PromptAccess + text.ts (242 lines) Pure string algorithms, including findChar and firstNonBlank + tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) + textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib) + util.ts (29 lines) State-agnostic primitives: translateKey, PASS, pushN, characterArgument, oppositeDirection + state.ts (77 lines) VimState lifecycle + transitions, editor-local lastFind + insert.ts (32 lines) handleInsertKey + normal.ts (426 lines) handleNormalKey, operator ranges, I and character finds/repeats + visual.ts (142 lines) handleVisualKey, character finds/repeats; cursor moves precede selection on V2 leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe) version.ts (46 lines) Version constant, GitHub update check (cached daily) test/ - vim.test.ts (1882 lines) Characterization tests for all key handling branches - leader.test.ts (125 lines) Unit tests for leader key matching functions + support.ts (33 lines) Shared assertion helpers + ev() + fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt + vim/ Per-module engine tests mirroring src/vim/: + text.test.ts (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units + state.test.ts (70) createVimState, toggleVimMode + util.test.ts (31) translateKey + insert.test.ts (92) handleInsertKey + normal.test.ts (823) handleNormalKey branches + visual.test.ts (287) handleVisualKey branches + textobject.test.ts (64) resolveTextObject dispatch seam + find.test.ts (274) Fork-specific I, f/F and ;/, including counts, ranges, cancellation and one-shot mode + integration.test.ts (662) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking + editor.test.ts (118) Host-coordinate boundaries and opaque selection-color forwarding + v2.test.ts (428) Experimental v2 facade contract and lifecycle tests + child-session-navigation.test.ts (267) #79: isolated intercept and real OpenTUI keymap navigation regressions + question-answer.test.ts (687) #78: common v1/v2 answer editing, isolated find history and host-key ownership regressions + question-answer-support.ts (471) Typed focused editor fixture with real OpenTUI keymap dispatch; optional cursor-clears-selection semantics + 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) + e2e/ Terminal Control guards: shared harness, v1/v2 question setup fixtures, *.integration.ts and *.e2e.ts explicit entries (not unit-discovered or packaged) ``` **Data flow:** @@ -72,10 +108,10 @@ test/ KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey() → HandlerResult { consume, actions[] } ↓ ↓ mutates VimState applyActions() in index.ts - (count, pendingOp, pendingChar, mode) dispatches commands via setTimeout + (count, pending, mode) dispatches commands via setTimeout ``` -Handlers in `vim.ts` are pure — they take state + key + event, mutate state, return actions. They never touch `api`. The only file that calls `api.keymap.dispatchCommand` is `index.ts`. +Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they take state + key + event, mutate state, return actions. They never touch `api`. The only file that calls `api.keymap.dispatchCommand` is `index.ts`. `src/vim/index.ts` is a strict barrel: it re-exports only the public surface (no `export *`, no internal helpers), and sibling modules import each other directly (`./types`, `./state`, …) never through the barrel. **Action types:** - `{ type: "cmd", cmd: string }` — dispatched via `setTimeout(() => api.keymap.dispatchCommand(cmd), 0)` @@ -92,14 +128,14 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r ### Adding a keybinding -1. In `vim.ts`, find the right section in `handleNormalKey()` (motions, operators, special keys, insert entries) +1. In `src/vim/normal.ts`, find the right section in `handleNormalKey()` (motions, operators, special keys, insert entries) 2. Add the key check and return appropriate actions: ```ts if (key === "yourkey") { return { 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 `test/vim/*.test.ts` (e.g. `test/vim/normal.test.ts`), importing helpers from `../support` and fixtures from `../fixtures`: ```ts it("yourkey dispatches some.command", () => { const result = handleNormalKey(state, "yourkey", ev("yourkey"), mockPrompt) @@ -110,12 +146,21 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r ### Adding an operator+motion combo -Operators (d/c/y) use two tables: `MOTIONS` maps key → standalone cursor command, `DELETE_MOTION` maps key → destructive command. When `pendingOp` is set and a motion key arrives, `handleNormalKey` looks up `DELETE_MOTION[key]` and dispatches it. +Operators (d/c/y) use two tables in `src/vim/tables.ts`: `MOTIONS` maps key → standalone cursor command, `DELETE_MOTION` maps key → destructive command. When an operator is pending and a motion key arrives, `handleNormalKey` (in `src/vim/normal.ts`) looks up `DELETE_MOTION[key]` and dispatches it. To add a new motion that works with operators: 1. Add the standalone motion to `MOTIONS`: `{ "yourkey": "input.move.whatever" }` 2. Add the destructive version to `DELETE_MOTION`: `{ "yourkey": "input.delete.whatever" }` -3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `pendingOp && key in MOTIONS` section. +3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `state.pending.kind === "operator" && key in MOTIONS` section. + +### Adding a text object + +Text objects (`iw`/`aw`, the quote/bracket pairs, and the `iq`/`ib` aliases) route through one seam, so the handlers never change: + +1. Add the pure range algorithm to `src/vim/text.ts` — e.g. `wordRange`, `bracketRange`, `quoteRange`. It takes `(text, offset, around)` (plus the delimiters for pairs) and returns an inclusive `Range` or `null` when there's nothing to select. +2. Add a `case` to `resolveTextObject` in `src/vim/textobject.ts` mapping the object char to that algorithm. This is the only dispatch point — the "any quote" (`q` → `anyQuoteRange`) and "any bracket" (`b` → `anyBracketRange`) aliases compose over the per-delimiter functions, picking the tightest enclosing pair. +3. No handler change needed. `normal.ts` (operator + `i`/`a` → textobject pending → `deleteRange`/`yank`/insert) and `visual.ts` (`i`/`a` → textobject pending → `selectRange`) already send every object char through `resolveTextObject`. +4. Test the range algorithm in `test/vim/text.test.ts` and the dispatch in `test/vim/textobject.test.ts`; add handler branches in `normal.test.ts`/`visual.test.ts` only if the object needs new handling. ### Known limitations @@ -125,17 +170,28 @@ To add a new motion that works with operators: ## 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 check # Lint + existing fast tests (used in GitHub Actions) +just test-int # On-demand SDK/process integration and parser helpers +just test-e2e # Both pinned PTY hosts (v1 1.18.34, v2 2.0.15); optional v1/v2 selector +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"]`. Both dev commands retain arrow navigation and add `ctrl+x j` to open children/picker; v1 uses `h`/`l` to cycle and `k` to return, while v2 uses `h`/`k` and `j`/`l` in the Composer. 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; the Python/tmux live-host checks remain manual. Separate PR jobs run `just test-int` and `just test-e2e` on pinned v1/v2 hosts; harness parser checks are units, driver lifecycle checks are SDK/process integration, and real-host checks are product E2E. These entries use non-discovered `.unit.ts`/`.integration.ts`/`.e2e.ts` suffixes, so ordinary `bun test` and `just check` remain fast and do not launch the guards. `scripts/test-e2e.ts` infers canonical fresh OS-temp outputs, prints receipts, and privately bootstraps exact pins only when no matching PATH/override binary exists. Optional ignored `.env` recognizes only `VIMCODE_E2E_V1_BIN` / `VIMCODE_E2E_V2_BIN` paths (relative to repo root); process values win, CI ignores the file, and unknown/provider keys are never forwarded. Use `bun --env-file=/dev/null run test:e2e`, not bare `bun run`, on Bun 1.3.2 to prevent outer dotenv autoload; synthetic launcher integration tests verify suppression. The advanced `just test-e2e HOST BINARY VERSION OUTPUT` form and CI artifact paths remain supported. See `docs/test-e2e.md` for network/bootstrap limits (local v2 registry access blocked), Linux CI/startup diagnostics and the unchanged private SDK 1.2.1 cleanup boundary. + +Question answer editing uses the shared controller on both hosts: require the focused editor's `traits.status === "ANSWER"` plus active question/form ownership, never a renderable id or an arbitrary textarea. Each answer editor has its own Vim state, snapshots, visual owner and leader sequence; the main prompt's context is suspended intact. Enter/Ctrl+Enter/Tab remain host-owned. Deferred editor actions validate focus, route, question ownership and disposal. Pending answer actions, including `:` palette transitions, drain in input order before the next key or native paste is handed to the host. Global palette actions still survive editor focus changes. During asynchronous palette mounting, keys and paste wait for the dialog's focused editor and are reconstructed through `keyInput.processParsedKey()`/`processPaste()`, one per turn; focus loss, closure or disposal drops remaining buffered input. Disabling Vim restores the owned editor's line cursor without styling unrelated overlays. v2 still protects printable leaders in non-answer textual forms without interpreting their other keys as Vim commands. + +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 **Never commit, push, or create PRs unless explicitly asked.** Present the changes and wait for the human to decide when to commit. +Upstream integration baseline: `b598916` (v0.19.2). Fork release: `v0.20.0`. Use ordinary minor/patch versions for fork releases and document the upstream baseline separately. Preserve `origin` and the fork's update-check URL. Fork tag `v0.16.0` is not upstream tag `v0.16.0`; fetch upstream with `--no-tags` and never overwrite either history. + All changes go through pull requests. Direct pushes to `main` are blocked. CI (`just check`) must pass before merge. PRs are squash-merged — the PR title becomes the commit on `main`. Branch naming: `type/description` — e.g. `feat/replace-char`, `fix/escape-handling`. Types match commit prefixes (`feat`, `fix`, `refactor`, `chore`, `test`, `docs`). @@ -146,7 +202,9 @@ Branch naming: `type/description` — e.g. `feat/replace-char`, `fix/escape-hand **No classes.** Use plain objects for state (`VimState`), plain functions for behavior. Pass state by reference, mutate it directly. Return results as data. -**Single responsibility per file.** `vim.ts` owns all key handling logic and state transitions. `index.ts` owns all OpenCode API interaction. `clipboard.ts` owns platform I/O. Don't mix these concerns. +**Single responsibility per file.** The `src/vim/` engine is split by concern: `types.ts` (data), `text.ts`/`tables.ts` (pure algorithms + keybinding maps), `util.ts`/`state.ts` (state-agnostic primitives + VimState lifecycle), and one handler per mode (`insert.ts`/`normal.ts`/`visual.ts`). `src/index.ts` owns all OpenCode API interaction. `clipboard.ts` owns platform I/O. Don't mix these concerns. + +**Barrel firewall + import discipline.** `src/vim/index.ts` re-exports only the public surface (explicit named re-exports, no `export *`, no internal helpers). Sibling modules import each other directly (`./types`, `./state`, `./text`, `./tables`, `./util`) and must never import from the barrel `./index` — barrel-import + barrel-re-export is a circular-import trap that can hand back `undefined` at runtime. Nothing under `src/vim/` may touch the plugin `api`. **Comments explain why, not what.** The code should read clearly without narration. Reserve comments for non-obvious decisions (like why `setTimeout` is needed for dispatch, or why `g` doesn't wait for a second keypress). @@ -156,11 +214,11 @@ Branch naming: `type/description` — e.g. `feat/replace-char`, `fix/escape-hand **Every mode transition emits a `mode` action.** The `Mode` type is the single source of truth for all displayable modes — including transient states like `"(insert)"` (one-shot normal). Never represent a mode as a separate boolean flag with a toast side-channel. If something changes what mode the user is in, it goes through the `Mode` type and a `{ type: "mode" }` action. -**Keep `vim.ts` under 500 lines.** If it grows past that, split by concern (motions, operators, insert entries). The handlers are already structured with clear sections — those become natural file boundaries. +**Keep each `src/vim/` module focused and under 500 lines.** The engine was split out of a single `vim.ts` once it crossed that line; the handlers already have clear internal sections, so if one grows past 500 again, split it further by concern. The barrel (`index.ts`) stays a pure re-export firewall — never add logic there. **Shifted key translation** happens in `translateKey()` before the handler sees the key. Handlers work with normalized keys (`$` not `shift+4`, `G` not `shift+g`). Add new shift mappings in `translateKey`, not in handlers. -**TypeScript strictness.** `strict: true` in tsconfig. No `any` in `vim.ts` or `test/`. The `api` parameter in `index.ts` is typed as `any` because the plugin types come from peer deps that may not be installed locally — that's the one acceptable use. +**TypeScript strictness.** `strict: true` in tsconfig. No `any` in `src/vim/` or `test/`. The `api` parameter in `index.ts` is typed as `any` because the plugin types come from peer deps that may not be installed locally — that's the one acceptable use. **Cross-platform.** All code must work on macOS, Linux, and Windows. No platform-specific assumptions without a runtime `process.platform` check and fallbacks for other platforms. diff --git a/BACKLOG.md b/BACKLOG.md index 8e4deca..45b0f91 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -26,7 +26,7 @@ Ordered by priority within each category. ## New features -1. **Text objects (`ciw`, `diw`, `yiw`, `ci"`, `di"`, `da(`, etc.).** Feasible now that we have cursor position access. Read `plainText` + `cursorOffset`, compute the object range in pure logic, apply the edit via `setSelection` + `deleteSelectedText` or direct text manipulation. Start with word and quote objects, then add bracket/paren. +1. ~~**Text objects (`ciw`, `diw`, `yiw`, `ci"`, `di"`, `da(`, etc.).**~~ Done. Word objects (`iw`/`aw`), quote and bracket pairs, and the `iq`/`ib` any-type aliases, all via the pure `resolveTextObject` seam. 2. **Visual-line mode (`V`).** The widget's `getLineInfo()` and `setSelection()` make line-wise selection straightforward. Extend the existing visual mode with a `visual-line` variant. @@ -44,7 +44,7 @@ Ordered by priority within each category. 9. **Completion-aware j/k.** When the cursor follows `@` or `/` (autocomplete triggers), normal-mode j/k should navigate the completion popup rather than move the cursor. -10. **Persistent mode indicator.** Replace the fading toast with something persistent. Blocked by the no-external-imports limitation for slot-based UI. Might be possible via `api.renderer.keyInput.processParsedKey()` or another workaround. +10. **Persistent mode indicator.** Replace the fading toast with something persistent. Still blocked upstream as of 2026-09-08: on OpenCode 1.18.21, a cache-installed `.tsx` slot fails to resolve `@opentui/solid/jsx-dev-runtime`. OpenCode 1.18.25 uses the same relevant runtime code and OpenTUI version. Track details in [#3](https://github.com/oribarilan/vimcode/issues/3). ## Polish diff --git a/CHANGELOG.md b/CHANGELOG.md index f305d3a..17d1324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,113 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] -## [0.16.0] — 2026-08-09 +## [0.20.0] — 2026-10-22 + +### Changed + +- Integrate upstream v0.19.2 (`b598916`) for experimental OpenCode V2 support, modular Vim handlers, text objects, question-answer editing, and compatibility tests, retaining V1 compatibility. +- Port the fork's `I`, `f`/`F`, and `;`/`,` bindings to the modular engine and editor-local state; preserve the fork's repository and update-check destination. +- Release the V2 migration as minor version `0.20.0`; existing tags are unchanged. +- Validate the fork on OpenCode 2.1.3 on macOS ARM64: 86 installed-package checks and 19 local-source checks pass, with inherited tab-offset and snapshot-redo gaps unchanged. + +### Fixed + +- Visual character finds move the cursor before applying selection, since the V2 cursor setter clears selections. +- Pending `f`/`F` motions can find a printable leader character instead of starting an OpenCode leader sequence. +- Backward character finds no longer re-match column zero after exhausting earlier occurrences. +- The compatibility driver escapes literal `;` for tmux, so repeat-find host checks actually deliver the key. + +## [0.19.2] — 2026-10-14 + +### Added + +- Added separate PR CI guards via `just test-int` for harness integration and `just test-e2e` for cache-installed OpenCode v1/v2 E2E editing, with exact prompt checks and no-plugin controls; local runs remain on demand and outside the unit suite. +- Added no-argument `just test-e2e` defaults for both pinned hosts, optional single-host selection, fresh temp receipts, private pinned bootstrap, and ignored path-only `.env` binary overrides; existing explicit CI paths remain supported. +- Added v1/v2 Terminal Control regressions for answer append followed by native paste in one PTY write, with synthetic question setup fixtures and exact visible-answer checks. + +### Fixed + +- Custom question answers now support Vim editing on both OpenCode v1 and v2, starting in insert mode with isolated mode/undo state and preserving native answer submission, choice navigation and permissions ([#78](https://github.com/oribarilan/vimcode/issues/78)). +- Native paste in question answers now waits for queued Vim motions, preventing insertion at the previous cursor position. +- E2E startup failures retain the native exit status and current-scenario host error log instead of only reporting a closed readiness stream. + +## [0.19.1] — 2026-10-04 + +### Changed + +- Added Vim-style subagent-navigation aliases to `just dev` and `just dev2` alongside the arrow bindings for manual checks. + +### Fixed + +- Remapped navigation keys now reach OpenCode in read-only child-session views without changing the Vim mode ([#79](https://github.com/oribarilan/vimcode/issues/79)). +- Corrected v2 root-session metadata so root prompts continue receiving Vim commands. +- Declared the OpenTUI keymap dev dependency so navigation regression tests run from clean installs. + +## [0.19.0] — 2026-10-02 + +### Added + +- Experimental OpenCode v2 TUI support using a dual v1/v2 entrypoint and a renderer-key interception adapter, validated on OpenCode 2.0.15 on macOS. +- 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 + +- Started a gradual transition toward v2-only maintenance. v1 compatibility remains in v0.19.0; existing v1 users can stay pinned to the recommended v0.18.1 release. +- Document Git installation, options, and custom leader settings separately for OpenCode v1 and experimental v2. +- Corrected contributor paths for the split Vim engine and documented CI's compatibility-harness checks. + +### Fixed + +- Visual `h`/`l` now selects through the cursor in both tested hosts, including backward motions, empty buffers and EOF/EOL boundaries, without resetting the native anchor for subsequent word or line motions. Visual mode re-anchors at the new prompt's cursor when the focused editor changes. +- The v2 form leader guard and Vim controller now share a per-activation disabled setting; external TUI toggles take effect on reload, local `/vim` toggles take effect immediately. +- Compatibility checks ignore user tmux configuration, reject JSONC project-config ancestors, and poll boundedly for asynchronous form/permission completion, retaining the last server response in receipts. +- Selection normalization no longer requires an installed `@opentui/core` type dependency; host-owned selection colors are forwarded unchanged. + +## [0.18.1] — 2026-09-11 + +### Changed + +- Updated the documented cache-installed mode indicator limitation with the latest OpenCode and OpenTUI findings ([#3](https://github.com/oribarilan/vimcode/issues/3)). + +### Fixed + +- Fixed Vim keys remaining unresponsive after dismissing or rejecting a question prompt ([#72](https://github.com/oribarilan/vimcode/issues/72)). + +## [0.18.0] — 2026-09-02 + +### Added + +- Word text objects `iw` and `aw`, for use with the `d`, `c`, and `y` operators and in visual mode: `diw`, `ciw`, `daw`, `viw`, and so on ([#57](https://github.com/oribarilan/vimcode/issues/57)). +- Quote and bracket text objects `i"` `i'` `` i` `` `i(` `i{` `i[` `i<` (with `a` variants) for `d`/`c`/`y` and visual mode: `di"`, `ci(`, `vi[`, and so on. `iq`/`ib` match the nearest quote/bracket of any type; `ib` means any bracket, not vim's parens-only `ib`. + +### Fixed + +- After an operator, `i`/`a` now start a text object instead of switching to insert mode, so `di` and `ci` wait for the object key ([#57](https://github.com/oribarilan/vimcode/issues/57)). + +## [0.17.1] — 2026-09-02 + +### Fixed + +- Arrow keys no longer get swallowed in normal and visual mode. They pass through to OpenCode, so you can exit the subagent view (and use other native navigation) without first switching to insert mode ([#63](https://github.com/oribarilan/vimcode/issues/63)). + +## [0.17.0] — 2026-09-02 + +### Changed + +- Redesigned the vim engine into a modular architecture for easier maintenance and future keybindings. No change to existing keybindings or behavior. + +### Fixed + +- `dgg` and `drx` no longer leave a stray operator that affected the next keypress. + +## [0.16.0] — 2026-08-31 + +### Added + +- `:w` and `:write` now send the prompt instead of autocompleting to `:wq` and quitting OpenCode. Sessions auto-persist, so the natural mapping for vim users' reflexive "save" is to submit the current prompt. `:wq` still quits. + +## [Fork 0.16.0] — 2026-08-09 ### Added @@ -18,7 +124,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Fixed -- Backward `F` motions now exclude the matched character from operator ranges, matching Vim. +- Backward `F` motions exclude the matched character from operator ranges. ## [0.15.3] — 2026-07-01 @@ -317,8 +423,17 @@ First release. Modal editing for the OpenCode prompt. > `g` fires immediately as buffer-home instead of waiting for `gg`. The `yy` line tracker drifts on clicks and arrow keys. Visual mode and text objects aren't feasible without cursor position access. -[Unreleased]: https://github.com/rohit-pathak/vimcode/compare/v0.16.0...HEAD -[0.16.0]: https://github.com/rohit-pathak/vimcode/compare/c1c5c47...v0.16.0 +[Unreleased]: https://github.com/rohit-pathak/vimcode/compare/v0.20.0...HEAD +[0.20.0]: https://github.com/rohit-pathak/vimcode/compare/v0.16.0...v0.20.0 +[Fork 0.16.0]: https://github.com/rohit-pathak/vimcode/compare/c1c5c47...v0.16.0 +[0.19.2]: https://github.com/oribarilan/vimcode/compare/v0.19.1...v0.19.2 +[0.19.1]: https://github.com/oribarilan/vimcode/compare/v0.19.0...v0.19.1 +[0.19.0]: https://github.com/oribarilan/vimcode/compare/v0.18.1...v0.19.0 +[0.18.1]: https://github.com/oribarilan/vimcode/compare/v0.18.0...v0.18.1 +[0.18.0]: https://github.com/oribarilan/vimcode/compare/v0.17.1...v0.18.0 +[0.17.1]: https://github.com/oribarilan/vimcode/compare/v0.17.0...v0.17.1 +[0.17.0]: https://github.com/oribarilan/vimcode/compare/v0.16.0...v0.17.0 +[0.16.0]: https://github.com/oribarilan/vimcode/compare/v0.15.3...v0.16.0 [0.15.3]: https://github.com/oribarilan/vimcode/compare/v0.15.2...v0.15.3 [0.15.2]: https://github.com/oribarilan/vimcode/compare/v0.15.1...v0.15.2 [0.15.1]: https://github.com/oribarilan/vimcode/compare/v0.15.0...v0.15.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 628d273..c5581db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,17 +4,23 @@ ```bash just install # install deps -just dev # launch OpenCode with the plugin loaded -just check # run lint + tests +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 + existing fast tests +just test # existing fast suite +just test-int # on-demand harness integration checks (including launcher) +just test-e2e # both pinned hosts; inferred private binaries/temp outputs ``` -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). + +`just test-e2e` runs v1@1.18.34 and v2@2.0.15; add `v1` or `v2` to select one. It prints fresh canonical temp receipt paths and may download/install the pinned hosts privately. Optional ignored `.env` path keys `VIMCODE_E2E_V1_BIN` / `VIMCODE_E2E_V2_BIN` use existing binaries; process settings win, CI ignores the file, and unknown keys are not forwarded. The advanced `HOST BINARY VERSION OUTPUT` form remains available. See [the runner settings and validation limits](docs/test-e2e.md) before using overrides (Linux runtime remains pending). ## 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. @@ -38,7 +44,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`, plus `just test-int` for harness integration and `just test-e2e` for product E2E on pinned OpenCode v1/v2. Warnings and guard failures fail the build. Locally these guards run only on demand; the existing Python/tmux live-host compatibility harness remains manual. See [the guard commands and limits](docs/test-e2e.md). An owner must configure the new check names as required after their first successful Actions run. 5. PRs are squash-merged. The PR title becomes the commit message on `main`. ## Commit messages @@ -57,6 +63,10 @@ Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Add your change - **MINOR**: New keybindings, new features (backward compatible) - **MAJOR**: Breaking changes, removed keybindings +## Host support policy + +OpenCode v2 support is rolling out gradually and remains experimental in vimcode v0.19.2. Future maintenance will move to v2 only; v1 compatibility remains in this release. The recommended v1 pin stays at `v0.18.1`. Do not automatically advance that legacy recommendation when preparing a v2-focused release. + ## Release process Releases are manual. @@ -67,7 +77,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 the current v2 package refs and release wording in `README.md` and this guide, including options and leader examples. Keep the recommended v1 pin unchanged unless a v1 upgrade is deliberately recommended and verified. 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. @@ -76,14 +86,30 @@ Releases are manual. ## Distribution -vimcode is installed via git URL in OpenCode's `tui.json`: +This fork's release is `v0.20.0`, based on upstream v0.19.2. Use ordinary minor/patch versions for fork releases and record the upstream baseline in the changelog, not in a personal version suffix. README has the fork's install instructions; the upstream examples below are historical references and do not include fork-specific bindings. Preserve existing fork tags: fork `v0.16.0` and upstream `v0.16.0` refer to different releases. + +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`. The recommended legacy pin is `v0.18.1`; `v0.19.2` includes v1 compatibility plus the subagent-navigation and question-answer fixes for users who opt in: + +```json +{ "plugin": ["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.18.1"] } +``` + +On OpenCode **v2**, use `cli.json` and pin `v0.19.2`. v2 support is experimental; the v1-only `v0.18.1` release does not work on v2: ```json -{ "plugin": ["vimcode@git+https://github.com/rohit-pathak/vimcode.git"] } +{ + "plugins": [ + { + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.2" + } + ] +} ``` -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 eef41ef..48cd8d4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@

Install · + Support policy · Configuration · What it does · Keybindings · @@ -20,35 +21,92 @@ --- +## Support policy + +This fork's **v0.20.0** builds on upstream **v0.19.2** and preserves `I`, `f`/`F`, and `;`/`,` from the fork's v0.16.0. Upstream provenance is tracked in the changelog, while this fork uses its own minor releases. + +OpenCode v2 support is rolling out gradually, starting with experimental support in vimcode v0.19.0. Future maintenance will move to v2 only. This release still includes v1 support. + +| OpenCode version | Recommended fork pin | Status | +|------------------|-------------------------|--------| +| v1 | `v0.20.0` | Retained compatibility; E2E tested on OpenCode 1.18.34 on macOS. | +| v2 | `v0.20.0` | Experimental; installed-package checks on OpenCode 2.1.3 and E2E checks on 2.0.15 on macOS. | + +V1 users can stay on an older pinned release instead of following the v2 rollout. We have not set a date for removing v1 support. + ## Install +Use the instructions for your OpenCode major version. Both versions use the same package and release tag, but their config files differ. + +### OpenCode v1 + +Use `v0.20.0` for the fork's bindings plus upstream's subagent-navigation and question-answer fixes. Existing V1 users can stay pinned to the fork's `v0.16.0` if they prefer their previous behavior. Upstream's V1-only `v0.18.1` is another option, but does not include the fork's custom bindings. + Add to your `tui.json` (or `.opencode/tui.json`): ```json { - "plugin": ["vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.16.0"] + "plugin": ["vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.20.0"] } ``` > **Why a versioned ref?** OpenCode resolves `@latest` once and caches it forever. Bumping the version in your config is the only reliable way to get updates. -You'll see a toast when a newer version is available (can be turned off). +By default, you'll see a toast when a newer vimcode version is available. If you stay on the legacy v1 pin, set `updateCheck: false` using the [v1 options example](#opencode-v1-options) to suppress those notifications. + +### OpenCode v2 (experimental) + +Pin `v0.20.0` in `~/.config/opencode/cli.json`. Replace any previous vimcode entry so only one copy loads. The fork's old `v0.16.0` tag does **not** support v2. + +```json +{ + "plugins": [ + { + "package": "vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.20.0" + } + ] +} +``` + +See [the v2 POC instructions](docs/opencode-v2-poc.md) for isolated development, tarball installation, and compatibility limits. You can also replace `package` with `/absolute/path/to/vimcode` to load a local checkout. To roll back v2, remove the plugin entry; a V1-only release cannot be used as a v2 fallback. + +This fork passed 86 installed-package checks on OpenCode **2.1.3** on macOS ARM64, with the inherited tab-offset and snapshot-redo known gaps unchanged. Local-directory loading also passed 19 fork-binding checks. To try it without changing your live configuration, run `just dev2 /absolute/path/to/opencode`. ## Configuration -To pass options, use the tuple form in `tui.json`: +### OpenCode v1 options + +Use the tuple form in `tui.json`: + +```json +{ + "plugin": [["vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.20.0", { "updateCheck": false }]] +} +``` + +### OpenCode v2 options + +Put `options` alongside `package` in `cli.json`: ```json { - "plugin": [["vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.16.0", { "updateCheck": false }]] + "plugins": [ + { + "package": "vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.20.0", + "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 @@ -60,7 +118,19 @@ In normal mode, keys are vim commands. Unrecognized keys get swallowed so you do ### Overlay passthrough -When OpenCode shows its own UI (command palette, `/sessions`, the `@` file picker, question prompts, permission prompts) vimcode steps aside. All keys pass through to the overlay until it closes. +When OpenCode shows its own UI (command palette, `/sessions`, the `@` file picker, question choices, permission prompts) vimcode steps aside. Those controls keep their native keybindings. + +### Question answers + +On both OpenCode v1 and v2, a question's **Type your own answer** editor starts in insert mode with a line cursor. Escape switches to normal without closing the editor; motions, operators, text objects, visual mode and undo work on the answer. `i` returns to insert. A further Escape in normal mode reaches OpenCode's cancel handler, which can discard an unsubmitted draft on v1. + +Enter, Ctrl+Enter and Tab keep OpenCode's native question behavior, rather than vimcode's main-prompt submit/newline bindings. Each answer editor has its own Vim mode, pending commands, yank register and undo snapshots; returning to the main prompt restores its previous state. This also applies to child-session questions. `p` pastes the answer's own yank register, and empty-answer `j`/`k` never loads prompt history. + +`:` still opens the command palette, which owns its keys until it closes. Timeline and conversation-navigation Vim shortcuts are inactive while editing an answer. Question choices and confirmation screens remain host-controlled. + +### Subagent navigation + +Read-only child-session views pass keys to OpenCode without changing your Vim mode. On v2, the Composer also owns its navigation keys. Returning to the parent prompt preserves the mode you were using. ### Escape behavior @@ -68,13 +138,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 +154,21 @@ This allows, for example, to use the popular vim-style `space` leader, set it in } ``` -Note that OpenCode defaults the leader to `ctrl+x`. +On **v2**, set both the host keybind and the plugin option in `cli.json`: + +```json +{ + "keybinds": { "leader": "space" }, + "plugins": [ + { + "package": "vimcode@git+https://github.com/rohit-pathak/vimcode.git#v0.20.0", + "options": { "experimentalV2Leader": "space" } + } + ] +} +``` + +If they do not match, vimcode can swallow leader shortcuts or printable input. ### Platform notes @@ -139,6 +223,22 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 words. +### Text objects + +Text objects pair with `d`, `c`, `y` and select in visual mode (`viw`, `vi(`, `vib`, …). `i` takes the inside; `a` takes the delimiters too (for words, the trailing whitespace): + +| Object | Selects | +|--------|---------| +| `iw` `aw` | The word under the cursor | +| `i"` `i'` `` i` `` | Inside the quotes | +| `i(` `i{` `i[` `i<` | Inside the bracket pair (opener or closer) | +| `iq` | The nearest quote of any type (`"` `'` `` ` ``) | +| `ib` | The nearest bracket of any type (`()` `{}` `[]`) | + +So `diw`, `ci"`, `da(`, `yi{`, `vib`, `ciq` all work. `iw` covers the run under the cursor — word, punctuation, or whitespace; `aw` also takes the trailing whitespace (or leading, when there's none). Brackets nest and can span lines; quotes pair within the current line. + +**`ib` note:** it means "any bracket" — the nearest of `()` `{}` `[]` — not vim's parens-only `ib`. Use `i(` when you specifically want parentheses. + ### Insert entries | Key | Action | @@ -177,6 +277,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b | `Ctrl+r` | Redo | | `p` | Paste from yank register | | `:` | Command palette | +| `:w` `:write` | Send prompt (via command palette) | | `:q` `:quit` `:wq` | Quit OpenCode (via command palette) | | `:vim` | Toggle vim mode on/off (persisted across restarts) | | `/` | Jump to message (session timeline) | @@ -191,14 +292,13 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b ## Known gaps - `Ctrl+v` - block visual mode is not supported -- `ciw`, `di"`, etc. (text objects) - not yet implemented -- No persistent mode indicator - the toast fades after about a second. A slot-based indicator needs the host's JSX runtime, which doesn't resolve reliably from git-installed plugins ([#3](https://github.com/oribarilan/vimcode/issues/3)). +- No persistent mode indicator - the toast fades after about a second. A cache-installed JSX slot still fails to resolve `@opentui/solid/jsx-dev-runtime`; last reproduced on 2026-09-08 with OpenCode 1.18.21. OpenCode 1.18.25 uses the same relevant runtime code and OpenTUI version ([#3](https://github.com/oribarilan/vimcode/issues/3)). 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 @@ -207,6 +307,8 @@ vimcode registers a key intercept on every prompt keypress. A pure handler in `s 3. Open issues for bugs or missing keybindings 4. PRs welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for dev setup and the release process. +Use `just test` for the fast suite, `just test-int` for harness integration and parser helpers, and **`just test-e2e` with no arguments** for both pinned OpenCode hosts (v1 **1.18.34**, v2 **2.0.15**). Optionally select `just test-e2e v1` or `v2`. The real-PTY guard infers fresh temp outputs, prints receipt paths, and bootstraps missing matching hosts privately (network required); optional ignored `.env` binary paths avoid bootstrap. PR builds run all three suites. The latter two stay outside fast-test discovery and run locally only on demand; see [path settings, advanced overrides, and validation limits](docs/test-e2e.md). + ## License MIT diff --git a/biome.json b/biome.json index 2678d06..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, @@ -9,15 +9,35 @@ "indentWidth": 2, "lineWidth": 120 }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "recommended": true, + "noExplicitAny": "error", + "noConsole": "error" + }, + "style": { + "recommended": true, + "noNonNullAssertion": "error" + } } }, "javascript": { "formatter": { - "quoteStyle": "double" + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always" } } } diff --git a/dev-tui.json b/dev-tui.json index 7d7ecbb..a634ab3 100644 --- a/dev-tui.json +++ b/dev-tui.json @@ -1,6 +1,10 @@ { "plugin": ["."], "keybinds": { - "leader": "ctrl+x" + "leader": "ctrl+x", + "session_child_first": "down,j", + "session_parent": "up,k", + "session_child_cycle": "right,l", + "session_child_cycle_reverse": "left,h" } } diff --git a/docs/opencode-v2-poc.md b/docs/opencode-v2-poc.md new file mode 100644 index 0000000..e3cc2fe --- /dev/null +++ b/docs/opencode-v2-poc.md @@ -0,0 +1,109 @@ +# Experimental OpenCode v2 POC + +## Fork migration (2026-10-22) + +The fork's `v0.20.0` release integrates upstream `b598916` (v0.19.2), then ports `I`, `f`/`F`, and `;`/`,` into the modular engine. Find commands use the pending-state union and reuse upstream's operator-range handling. Each question answer has its own remembered find. The fork repository and update-check URL remain unchanged. Validation below was performed on the migration implementation before the release-metadata bump. + +The unmodified upstream artifact passed 64 exact-state checks on OpenCode **2.1.3** on macOS ARM64, with two inherited known gaps. A local-directory installation of the port passed 19 additional fork-binding checks, including visual selections in both directions, printable space-leader targets, undo, and one-shot normal mode. V2 cursor movement clears selection, so visual find actions move first and select afterwards. The tmux driver escapes bare semicolons; otherwise tmux treats repeat-find input as a command separator instead of delivering it. + +The final fork artifact passed cold and warm package-cache loading and **86 exact-state checks** on OpenCode **2.1.3**, with zero failures and the unchanged `snapshot-redo` / `tab-inner-word-offset` known gaps. Checks include separate main/answer find histories, restored main-prompt direction, forward/backward visual selection and deletion, root/child forms, permissions, leader handling, autocomplete, disable and unload/reload. A retained-V1 run on OpenCode 1.18.34 passed 56 checks with the same two inherited known gaps before the V2-specific visual action-order fix; the final V1/V2 Terminal Control suites passed again after that fix. + +`just check` passes 506 tests plus lint, `just compat-unit` passes 17 harness checks, and `just test-int` passes 32 parser/process checks. Typechecking retains the same six errors measured in the clean upstream baseline (missing Node declarations and update-check callback/KV types); the port adds no diagnostics. The pinned Terminal Control suites also passed five scenarios each on OpenCode 1.18.34 and 2.0.15. These are bounded checks, not a claim of full Unicode, cross-platform or all-V2-version support. + +For this fork's local source on an installed V2 binary, use `just dev2 /absolute/path/to/opencode`. For package-cache validation on that version, use a fresh output directory outside any checkout: + +```sh +just compat v2 /absolute/path/to/opencode 2.1.3 /canonical/empty/output +``` + +The remaining sections preserve upstream's historical evidence and release pins; those upstream tags do not include this fork's custom bindings. Use the fork installation instructions in README instead. + +## Upstream notes + +These notes record the v2 proof of concept and compatibility evidence. v2 support is experimental in vimcode v0.19.2; this is not a full-parity or all-versions support declaration. 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`. + +Both `just dev` (v1) and `just dev2` (v2) include subagent-navigation aliases for manual checks, alongside the native arrow bindings. `ctrl+x j` opens the child view/picker. On v1, `h`/`l` cycles children and `k` returns to the parent. On v2, `h`/`k` moves up, `j`/`l` moves down, Enter selects a child, and Escape closes the Composer. At the first picker row, `h`/`k` also closes it. No config editing or separate test launcher is needed. + +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. + +The [README](../README.md#opencode-v2-experimental) shows the release Git install pinned to `v0.19.2`. The earlier Git experiment below used 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 +{ + "plugins": [ + { + "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.19.2.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. 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) + +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. + +- 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/`. + +## Issue #79 follow-up (2026-10-04) + +The child-session passthrough guard now lives once in the shared controller. The v2 facade distinguishes root sessions from children before supplying `parentID`; otherwise the shared guard would incorrectly bypass root editing. The Vim engine is unchanged. Composer passthrough already existed and is now covered explicitly for normal, visual and insert modes. + +Fresh installed-artifact runs passed cold and warm loading on v1.18.33 (39 checks) and v2.0.15 (48 checks), with the same inherited tab-offset and snapshot-redo known gaps and no failures. Receipts: `/private/tmp/vimcode-79-dual-v1-live/receipt.json` and `/private/tmp/vimcode-79-dual-v2-live/receipt.json`. + +A separate real v2 navigation smoke passed nine checks using synthetic child sessions, remapped Composer keys, and exact route/editor snapshots. It verified `h`/`l` picker movement, `k` returning to the parent without `i`, and normal editing afterward. This used v2's `composer.subagent.up` (`h`, `k`), `composer.subagent.down` (`l`) and `composer.subagent.select` (`return`), not v1's sibling-cycle config names. The temporary fixture/driver is outside the checkout; receipt: `/private/tmp/vimcode-79-dual-v2-navigation-corrected/receipt.json`. + +## 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 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 +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. 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/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/docs/plans/2026-07-23-vim-engine-modularization.md b/docs/plans/2026-07-23-vim-engine-modularization.md new file mode 100644 index 0000000..a3fcb9d --- /dev/null +++ b/docs/plans/2026-07-23-vim-engine-modularization.md @@ -0,0 +1,700 @@ +# Vim Engine Modularization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the 656-line `src/vim.ts` into a focused `src/vim/` module tree with single-responsibility files, without changing any runtime behavior (Tasks 0–12). A trailing **Phase 4 (Task 13)** then consolidates the two pending fields into one `Pending` union — that step is a *deliberate, separately-committed behavioral change* (it removes untested dangling-operator states), explicitly outside the "no behavior change" guarantee. It lives here because it is a pure state-representation refactor of the just-extracted `state.ts`/handlers, and it is the prerequisite for the text-object feature plan. + +**Architecture:** `src/vim.ts` becomes a `src/vim/` directory. Pure concerns separate into leaf modules (`types`, `text`, `tables`, `util`), state lifecycle into `state`, and each key handler into its own file (`insert`, `normal`, `visual`). `src/vim/index.ts` is a thin barrel that re-exports only the public surface, so `./vim` keeps resolving and no consumer import changes. The existing 184-test suite is the safety net; every step keeps it green. + +**Tech Stack:** TypeScript (strict), Bun test runner, OpenCode TUI plugin API. + +## Global Constraints + +- Every step ends with `bun test` green. Baseline is 184 tests; Task 0 raises it. +- **Pure engine.** Nothing under `src/vim/` may touch the plugin `api`. Side effects stay in `src/index.ts`. +- **Import discipline.** Sibling modules import directly from each other (`./types`, `./state`, `./text`, `./tables`, `./util`). They must NEVER import from the barrel `./index`. (Barrel-import + barrel-re-export is the circular-import trap; Bun can hand back `undefined` at runtime.) +- **Barrel is a strict firewall.** `src/vim/index.ts` uses explicit named re-exports of the public surface only. No `export *`. Never re-export internal helpers (`resetPending`, `consumeCount`, `enterInsert`, `PASS`, `pushN`, `MOTIONS`, `SELECT_MOTIONS`, `DELETE_MOTION`). +- **Structural only.** Tasks 1-11 move code verbatim; they do not change behavior. The only content changes are Task 0 (new tests) and Task 10 (delete dead `_CONSUME`). +- **One module per commit.** Any bug found while hardening (Task 0) is fixed in its own commit, never mixed with an extraction. +- No `any` in `src/vim/` or `test/`. Keep every file under 500 lines. Cross-platform (macOS/Linux/Windows). + +## Public surface (what the barrel must re-export) + +Confirmed by grep — the only external consumers are `src/index.ts`, `src/leader.ts`, and the tests. + +- **Values:** `createVimState`, `toggleVimMode`, `translateKey`, `handleInsertKey`, `handleNormalKey`, `handleVisualKey`, `finishOneShotIfComplete`, `endOfWord` +- **Types:** `Action`, `VimState`, `KeyEvent`, `PromptAccess` + +`MOTIONS` and `SELECT_MOTIONS` are currently exported but used only inside `vim.ts`. They become internal to `tables.ts` and are NOT re-exported. + +## Target module structure + +`src/vim/` (all files pure, no `api` access): + +| File | Responsibility | Imports from | +|---|---|---| +| `types.ts` | `Action` union, `VimState`, `Mode`, `Operator`, `KeyEvent`, `HandlerResult`, `PromptAccess`. No logic. | (none) | +| `text.ts` | Pure string algorithms: `isWhitespace`, `charKind`, `endOfWord`, `currentLineRange`. Input string+offset → offset/range. (Landing zone for the future `wordRangeAt`.) | (none) | +| `tables.ts` | Static keybinding maps: `MOTIONS`, `SELECT_MOTIONS`, `DELETE_MOTION`. Pure data. | (none) | +| `util.ts` | Shared state-agnostic primitives: `translateKey`, `PASS`, `pushN`. | `types` | +| `state.ts` | VimState lifecycle + transitions only: `createVimState`, `toggleVimMode`, `finishOneShotIfComplete`, `resetPending`, `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual`. | `types` | +| `insert.ts` | `handleInsertKey`. | `types`, `util` | +| `normal.ts` | `handleNormalKey`. Owns the (inlined) single-use helpers `finishUndoableChange` and `isInputEmpty`. | `types`, `util`, `state`, `text`, `tables` | +| `visual.ts` | `handleVisualKey`. | `types`, `util`, `state`, `text`, `tables` | +| `index.ts` | Barrel. Explicit named re-exports of the public surface. | all | + +Dependency graph is acyclic: `types` / `text` / `tables` are leaves; `util` and `state` depend only on `types`; handlers depend on `types` + `util` + `state` (+ `text` + `tables` for normal/visual); the barrel re-exports. + +### Where the council review changed the original proposal + +1. `state.ts` is scoped to VimState-only. The state-agnostic helpers move to `util.ts` (`translateKey`, `PASS`, `pushN`); the single-use helpers `finishUndoableChange` and `isInputEmpty` are inlined into `normal.ts`. +2. Import discipline + strict barrel firewall are now hard constraints (see Global Constraints). +3. Dead `_CONSUME` is deleted (Task 10). +4. `text.ts` is extracted first (Task 3) as the feature's landing zone; `currentLineRange` lives there and `charKind` is exported for the upcoming text-object work. +5. Test split gives the integration/undo tests their own file rather than forcing them under a handler (Task 11). + +## Out of scope + +The text-object feature (issue #57: `diw`/`ciw`/`yiw`/`viw`/`daw`) is a **separate plan** that runs after this refactor. This plan only restructures and hardens. + +## Extraction convention (Tasks 2-9) + +"Move symbol X" means: cut X verbatim from `src/vim/index.ts` into the target file, add the import header shown, then in the barrel add the named re-export shown and import anything the barrel still needs internally. After each task, `src/vim/index.ts` shrinks and the new module is self-contained. Run `bun test` and commit. + +**Per-task barrel invariant.** Each extraction must add the barrel re-export in the *same commit* it removes the definition. Between commits the barrel must always re-export the full public surface, or `src/index.ts` / `src/leader.ts` / the tests break mid-sequence and the "every step ends green" guarantee fails. + +--- + +### Task 0: Harden the remaining coverage gaps + +Pin every branch that later moves. Source is untouched here except where a gap reveals a real bug (fix that in a separate commit). + +**Files:** +- Modify: `test/vim.test.ts` (add tests to existing describe blocks) + +**Interfaces:** +- Consumes: `handleNormalKey`, `finishOneShotIfComplete`, `ev`, `cmds`, `mockPrompt`, `state` (all already in the test file). +- Produces: nothing new; raises `src/vim.ts` line coverage toward 100%. + +- [ ] **Step 1: Add the missing-branch tests** + +First verify what is actually uncovered — do not add duplicates. The current suite **already tests** `Ctrl+R` → `input.redo` and `O` opens-a-line-above; skip those two below. Confirm the rest are genuinely missing (run `bun test --coverage` and read the `vim.ts` gaps) before appending. Append only the real gaps to `test/vim.test.ts` in the matching describe blocks: + +```ts +// in: handleNormalKey — special keys +it("Ctrl+R dispatches input.redo", () => { + const result = handleNormalKey(state, "r", ev("r", { ctrl: true }), mockPrompt); + expect(result.consume).toBe(true); + expect(cmds(result.actions)).toEqual(["input.redo"]); +}); + +it("Enter in normal mode submits the prompt", () => { + const result = handleNormalKey(state, "return", ev("return"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.submit"]); +}); + +it("x deletes the character under the cursor", () => { + const result = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.delete"]); +}); + +it("3x deletes three characters", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const result = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); +}); + +// in: handleNormalKey — insert entries +it("O opens a line above and enters insert", () => { + const result = handleNormalKey(state, "O", ev("O"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.line.home", "input.newline", "input.move.up"]); + expect(result.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); +}); + +// in: Ctrl+O one-shot normal mode +it("finishOneShotIfComplete does not double-append insert when the result already enters insert", () => { + state.oneShotNormal = true; + const result = { consume: true, actions: [{ type: "mode", mode: "insert" } as const] }; + finishOneShotIfComplete(state, result); + expect(state.oneShotNormal).toBe(false); + expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); +}); +``` + +- [ ] **Step 2: Run the suite** + +Run: `bun test` +Expected: PASS. Count rises from the current baseline by however many of the proposed tests are genuinely new (fewer than six, since `Ctrl+R` and `O` are already covered). Don't treat an exact target number as a gate — the coverage delta in Step 3 is the real signal. + +- [ ] **Step 3: Confirm coverage closed** + +Run: `bun test --coverage` +Expected: `src/vim.ts` line coverage at/near 100%. The only permitted remaining gap is the operator+motion fallthrough (`vim.ts:411-412`): it is defensive code, unreachable because `j`/`k`/`G` are handled by earlier branches and every remaining `MOTIONS` key has a `DELETE_MOTION` entry. Leave it, with a one-line `// unreachable: every MOTIONS key reaching here has a DELETE_MOTION entry (j/k/G handled above)` comment. Do not contort a test to reach it. + +- [ ] **Step 4: Commit** + +```bash +git add test/vim.test.ts src/vim.ts +git commit -m "test: pin uncovered normal-mode branches before refactor" +``` + +--- + +### Task 1: Turn `vim.ts` into the directory barrel + +**Files:** +- Rename: `src/vim.ts` → `src/vim/index.ts` + +- [ ] **Step 1: Move the file, preserving history** + +```bash +git mv src/vim.ts src/vim/index.ts +``` + +- [ ] **Step 2: Verify resolution is unchanged** + +`src/index.ts` (`from "./vim"`), `src/leader.ts` (`from "./vim"`), and `test/vim.test.ts` (`from "../src/vim"`) all resolve to the new `src/vim/index.ts` automatically. + +Run: `bun test` +Expected: PASS (~190), no import edits needed. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: move vim.ts into src/vim/ directory barrel" +``` + +--- + +### Task 2: Extract `types.ts` + +**Files:** +- Create: `src/vim/types.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `Action`, `VimState`, `Mode`, `Operator`, `KeyEvent`, `HandlerResult`, `PromptAccess`. + +- [ ] **Step 1: Move the type declarations** + +Cut `Mode`, `Operator`, `Action`, `HandlerResult`, `VimState`, `KeyEvent`, `PromptAccess` (currently `index.ts` lines 1-49) into `src/vim/types.ts`. No imports needed — they are self-contained. + +- [ ] **Step 2: Re-wire the barrel** + +At the top of `src/vim/index.ts`: + +```ts +import type { Action, HandlerResult, KeyEvent, Mode, Operator, PromptAccess, VimState } from "./types"; + +export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; +``` + +(Import `Mode`/`Operator`/`HandlerResult` only because the code still living in the barrel references them; they are not re-exported.) + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract vim types into types.ts" +``` + +--- + +### Task 3: Extract `text.ts` (feature landing zone) + +**Files:** +- Create: `src/vim/text.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `endOfWord` (public), `charKind`, `isWhitespace`, `currentLineRange` (internal to the engine; consumed by handlers and, later, `wordRangeAt`). + +- [ ] **Step 1: Move the pure string functions** + +Cut `endOfWord`, `isWhitespace`, `charKind` (index.ts ~lines 123-150) and `currentLineRange` (~lines 617-623) into `src/vim/text.ts`. Export all four (`charKind` is exported now because the text-object work will classify boundary characters). No imports needed. + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { currentLineRange, endOfWord } from "./text"; + +export { endOfWord } from "./text"; +``` + +(Only `endOfWord` is public. `currentLineRange` is imported because the `V` handler still living in the barrel uses it.) + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract pure text algorithms into text.ts" +``` + +--- + +### Task 4: Extract `tables.ts` + +**Files:** +- Create: `src/vim/tables.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `MOTIONS`, `SELECT_MOTIONS`, `DELETE_MOTION` (all engine-internal). + +- [ ] **Step 1: Move the maps** + +Cut `MOTIONS`, `SELECT_MOTIONS` (index.ts lines 51-75) and `DELETE_MOTION` (lines 77-85) into `src/vim/tables.ts`. Export all three. No imports needed. + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; +``` + +Not re-exported (internal). The handlers still in the barrel use them via this import for now. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract keybinding tables into tables.ts" +``` + +--- + +### Task 5: Extract `util.ts` + +**Files:** +- Create: `src/vim/util.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `translateKey` (public), `PASS`, `pushN` (engine-internal). + +- [ ] **Step 1: Move the shared primitives** + +Cut `translateKey` (index.ts ~lines 152-162) and `pushN` (~lines 650-652) into `src/vim/util.ts`. Add `PASS` there too (currently defined ~line 87). Header: + +```ts +import type { Action, HandlerResult, KeyEvent } from "./types"; + +export const PASS: HandlerResult = { consume: false, actions: [] }; + +export function translateKey(ev: KeyEvent): string { /* moved verbatim */ } + +export function pushN(actions: Action[], cmd: string, n: number): void { /* moved verbatim */ } +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { PASS, pushN, translateKey } from "./util"; + +export { translateKey } from "./util"; +``` + +Delete the old inline `PASS`/`pushN`/`translateKey` definitions from the barrel. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract translateKey/PASS/pushN into util.ts" +``` + +--- + +### Task 6: Extract `state.ts` + +**Files:** +- Create: `src/vim/state.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `createVimState`, `toggleVimMode`, `finishOneShotIfComplete` (public); `resetPending`, `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual` (engine-internal). + +- [ ] **Step 1: Move state lifecycle + transitions** + +Cut `createVimState`, `toggleVimMode`, `finishOneShotIfComplete`, `resetPending`, `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual` into `src/vim/state.ts`. Header: + +```ts +import type { Action, HandlerResult, Mode, Operator, VimState } from "./types"; +``` + +Do NOT move `finishUndoableChange` or `isInputEmpty` here — they go into `normal.ts` in Task 8. + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { consumeCount, createVimState, enterInsert, enterNormal, exitVisual, resetPending } from "./state"; + +export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract VimState lifecycle into state.ts" +``` + +--- + +### Task 7: Extract `insert.ts` + +**Files:** +- Create: `src/vim/insert.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Consumes: `PASS` from `./util`; types from `./types`. +- Produces: `handleInsertKey`. + +- [ ] **Step 1: Move the handler** + +Cut `handleInsertKey` into `src/vim/insert.ts`. Header: + +```ts +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS } from "./util"; +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +export { handleInsertKey } from "./insert"; +``` + +Remove the now-unused `PASS` import from the barrel if nothing else there references it. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract handleInsertKey into insert.ts" +``` + +--- + +### Task 8: Extract `normal.ts` (with inlined single-use helpers) + +**Files:** +- Create: `src/vim/normal.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Consumes: `MOTIONS`, `DELETE_MOTION` from `./tables`; `endOfWord`, `currentLineRange` from `./text`; `PASS`, `pushN` from `./util`; `resetPending`, `consumeCount`, `enterInsert` from `./state`; types from `./types`. +- Produces: `handleNormalKey`. + +- [ ] **Step 1: Move the handler and inline its private helpers** + +Cut `handleNormalKey` into `src/vim/normal.ts`. Move `finishUndoableChange` and `isInputEmpty` here too (they are used only by this handler) as file-local functions. Header: + +```ts +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { DELETE_MOTION, MOTIONS } from "./tables"; +import { currentLineRange, endOfWord } from "./text"; +import { PASS, pushN } from "./util"; +import { consumeCount, enterInsert, resetPending } from "./state"; + +function finishUndoableChange(actions: Action[]): HandlerResult { /* moved verbatim */ } +function isInputEmpty(prompt: PromptAccess): boolean { /* moved verbatim */ } +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +export { handleNormalKey } from "./normal"; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract handleNormalKey into normal.ts" +``` + +--- + +### Task 9: Extract `visual.ts` + +**Files:** +- Create: `src/vim/visual.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Consumes: `SELECT_MOTIONS` from `./tables`; `endOfWord` from `./text`; `pushN` from `./util`; `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual` from `./state`; types from `./types`. +- Produces: `handleVisualKey`. + +- [ ] **Step 1: Move the handler** + +Cut `handleVisualKey` into `src/vim/visual.ts`. Header: + +```ts +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { SELECT_MOTIONS } from "./tables"; +import { endOfWord } from "./text"; +import { PASS, pushN } from "./util"; +import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +export { handleVisualKey } from "./visual"; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract handleVisualKey into visual.ts" +``` + +--- + +### Task 10: Finalize the barrel + drop dead code + +**Files:** +- Modify: `src/vim/index.ts` + +- [ ] **Step 1: Reduce the barrel to re-exports only** + +After Tasks 2-9, `src/vim/index.ts` should contain nothing but the public re-exports: + +```ts +export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; +export { endOfWord } from "./text"; +export { translateKey } from "./util"; +export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; +export { handleInsertKey } from "./insert"; +export { handleNormalKey } from "./normal"; +export { handleVisualKey } from "./visual"; +``` + +Delete any leftover internal imports. Confirm `_CONSUME` is gone (it was dead code; it must not have been carried into any module). + +- [ ] **Step 2: Verify the firewall** + +Two checks. First, the external shell must not reach any internal (anchor the fragile short names with `\b` so `PASS` doesn't match `bypass`/`compass`): + +Run: `grep -rnE "\b(MOTIONS|SELECT_MOTIONS|DELETE_MOTION|resetPending|consumeCount|PASS|pushN)\b" src/index.ts src/leader.ts` +Expected: no matches. The shell depends only on the public surface. + +Second, verify import discipline inside the engine — no sibling module imports the barrel (the circular-import trap): + +Run: `grep -rnE "from \"\.\/index\"|from \"\.\.\/vim\"" src/vim/` +Expected: no matches. Siblings import each other directly (`./types`, `./state`, …); only external consumers go through the barrel. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: reduce vim barrel to public surface, drop dead _CONSUME" +``` + +--- + +### Task 11: Split the test suite to mirror the modules + +**Files:** +- Create: `test/support.ts` (assertion helpers), `test/fixtures.ts` (prompt fixtures) +- Create: `test/vim/text.test.ts`, `test/vim/state.test.ts`, `test/vim/util.test.ts`, `test/vim/insert.test.ts`, `test/vim/normal.test.ts`, `test/vim/visual.test.ts`, `test/integration.test.ts` +- Delete: `test/vim.test.ts` + +**Interfaces:** +- `test/support.ts` produces: `cmds`, `cursorTos`, `deleteRanges`, `saveUndoSnapshots`, `selectRanges`, `ev`. +- `test/fixtures.ts` produces: `mockPrompt`, `emptyPrompt`. + +- [ ] **Step 1: Extract shared test helpers** + +Move `cmds`/`cursorTos`/`deleteRanges`/`saveUndoSnapshots`/`selectRanges`/`ev` (current `vim.test.ts` lines 16-46) into `test/support.ts`, and `mockPrompt`/`emptyPrompt` (lines 48-62) into `test/fixtures.ts`. Export each. + +- [ ] **Step 2: Split describe blocks into per-module files (one at a time)** + +Do this **incrementally**, never as a big-bang rewrite: a dropped or duplicated test passes CI silently (a missing test doesn't fail — it just vanishes), so a green suite alone does NOT prove conservation. Record the exact test count at the end of Task 0 first (`bun test` prints it). Then, for each target file below: cut its describe block(s) out of `vim.test.ts`, paste into the new file, run `bun test`, and confirm the **total count is unchanged**. The original file shrinks toward empty as you go; delete it last (Step 3). + +Move each describe block to the file that matches the module under test, importing from `../../src/vim` (barrel) and helpers from `../support` / `../fixtures`: + +- `text.test.ts` ← `endOfWord`; add direct unit tests for `charKind`, `isWhitespace`, `currentLineRange` (now exported from `text.ts`, importable via the barrel or directly from `../../src/vim/text`). +- `state.test.ts` ← `createVimState`, `toggleVimMode`. +- `util.test.ts` ← `translateKey`. +- `insert.test.ts` ← `handleInsertKey`. +- `normal.test.ts` ← the `handleNormalKey` blocks (motions, g prefix, e motion, operators, dG/cG, shortcuts, special keys, replace, insert entries, yy, history, visual-mode entry). +- `visual.test.ts` ← `handleVisualKey` blocks (motions, operators, exit/passthrough). +- `integration.test.ts` ← `Ctrl+O one-shot`, `plugin init`, and `undo snapshot` blocks (these drive the full pipeline / `applyActions`). Keep the `version sync` block here or in a small `test/version.test.ts`. + +Each split file re-declares the shared setup it needs — `let state; beforeEach(() => { state = createVimState(); state.mode = "normal"; })` and a `createVimState` import — since only the pure assertion helpers and prompt fixtures are centralized in `test/support.ts` / `test/fixtures.ts`. + +Consider doing Task 11 as its own PR, separate from the code-move commits, so a mis-split is easy to bisect. + +- [ ] **Step 3: Delete the old file, verify counts** + +Only after every block has moved and `vim.test.ts` holds no live tests: + +```bash +rm test/vim.test.ts +bun test +``` +Expected: the **exact** total from the end of Task 0 (the split moves tests, it does not add or drop them), plus only the new `text.ts` unit tests you deliberately added in Step 2. Completion criterion is count parity, not merely "green" — if the number dropped, a test was lost in the split. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "test: split vim tests to mirror the module layout" +``` + +--- + +### Task 12: Update docs + +**Files:** +- Modify: `AGENTS.md` (Architecture section, line counts, conventions) +- Modify: `CHANGELOG.md` (`[Unreleased]`) + +- [ ] **Step 1: Update AGENTS.md** + +Replace the `src/vim.ts (645 lines)` architecture block with the new `src/vim/` tree and per-file responsibilities. Update the data-flow diagram to reference the handler modules. Note the import-discipline and barrel-firewall rules under Code Conventions. Adjust the "Keep vim.ts under 500 lines" note to reflect the split. (AGENTS.md's current counts are stale — `src/vim.ts` is actually 656 lines, not 645, and the test-file line count is likewise off; re-measure with `wc -l` rather than trusting the old numbers.) + +- [ ] **Step 2: Update CHANGELOG.md** + +Under `[Unreleased]`, add: `### Changed — Split the vim engine into a modular src/vim/ tree (no behavior change).` + +- [ ] **Step 3: Run the full gate + commit** + +```bash +just check # lint + tests +git add AGENTS.md CHANGELOG.md +git commit -m "docs: document the modular vim engine layout" +``` + +--- + +## Phase 4 — Consolidate pending state (deliberate behavioral change) + +This phase is a state-representation refactor, distinct from the file moves in Tasks 1-12. It replaces the two orthogonal pending fields (`pendingOp` + `pendingChar`) with a single discriminated `Pending` union, so the two-fields-must-stay-consistent burden disappears and text objects get a correct home (Phase 4 is a prerequisite for the text-object feature plan). Do it after Task 12, on the already-extracted `types.ts` / `state.ts` / handler modules, **as its own commit (ideally its own PR)** so the behavior-preserving refactor (Tasks 1-12) stays independently bisectable. + +**This is NOT covered by the plan's "no runtime behavior change" banner.** It preserves every currently-*tested* behavior (the tests from Task 0 stay green), but it is a real behavior change: it removes untested *dangling-state* sequences the two-field model permits. `dgg` and `drx` today leave a stale operator set after the `g`/`r` branch runs, because the fields are independent — so `dgg` then `w` currently *deletes a word*. Collapsing to one field makes the `g`/`r` branch overwrite the operator, so the dangle is impossible by construction. Treat this as a bug fix, and pin both the cleared pending state and the corrected next-key behavior with new tests in Step 4. Real `dgg`-deletes-to-top support is out of scope (a later feature); this phase keeps `gg`'s current user-visible result (move to top, no delete). + +### Task 13: Replace `pendingOp` + `pendingChar` with a `Pending` union + +**Files:** +- Modify: `src/vim/types.ts`, `src/vim/state.ts`, `src/vim/normal.ts`, `src/vim/visual.ts` +- Modify: `test/vim/normal.test.ts`, `test/vim/visual.test.ts`, `test/vim/state.test.ts`, `test/integration.test.ts` + +**Interfaces:** +- Produces: the `Pending` union; `Operator` narrowed to non-null `"d" | "c" | "y"`. + +- [ ] **Step 1: Define the union in `types.ts`** + +```ts +export type Operator = "d" | "c" | "y"; // nullability moves into Pending + +export type Pending = + | { kind: "none" } + | { kind: "operator"; op: Operator } // replaces pendingOp + | { kind: "goto" } // replaces pendingChar === "g" + | { kind: "replace" }; // replaces pendingChar === "r" +``` + +In `VimState`, replace `pendingOp: Operator` and `pendingChar: "r" | "g" | null` with a single `pending: Pending`. + +- [ ] **Step 2: Update `state.ts`** + +- `createVimState`: initialize `pending: { kind: "none" }`. +- `resetPending`: `state.pending = { kind: "none" }; state.count = 0;`. +- `toggleVimMode`: reset to `{ kind: "none" }` where it currently clears the two fields. +- `finishOneShotIfComplete`: change the guard `state.pendingOp !== null || state.pendingChar !== null || state.count > 0` to `state.pending.kind !== "none" || state.count > 0`. + +- [ ] **Step 3: Translate the handler read/write sites (behavior identical)** + +| Old | New | +|---|---| +| `state.pendingOp = key` (d/c/y) | `state.pending = { kind: "operator", op: key }` | +| `state.pendingOp === "d"` / truthy | `state.pending.kind === "operator"` (op via `state.pending.op`) | +| `state.pendingOp === key` (dd/cc/yy) | `state.pending.kind === "operator" && state.pending.op === key` | +| `state.pendingChar = "g"` | `state.pending = { kind: "goto" }` | +| `state.pendingChar === "g"` | `state.pending.kind === "goto"` | +| `state.pendingChar = "r"` | `state.pending = { kind: "replace" }` | +| `state.pendingChar === "r"` | `state.pending.kind === "replace"` | + +The `g`/`r` branches now set `{ kind: "goto" }` / `{ kind: "replace" }`, which also clears any pending operator — that is the dangling-state cleanup (previously the operator leaked). + +**Narrowing gotcha.** After narrowing on `state.pending.kind === "operator"`, capture the operator into a local (`const op = state.pending.op`) at the top of the branch. `enterInsert`/`resetPending` reassign `state.pending`, which de-narrows the union — a later `state.pending.op` read in the same branch will not type-check. Read `op` once, then use the local. + +- [ ] **Step 4: Update field-asserting tests + pin the cleanup** + +Update the ~25-30 assertions that read `state.pendingOp` / `state.pendingChar` directly (the operators, g-prefix, and replace blocks). Add tests pinning the removed dangling state — both the cleared `pending` AND the corrected next keystroke (the latter is what actually regressed the two-field bug): + +```ts +it("dgg does not leave a dangling operator", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); +}); + +it("dgg then w moves by word (the old dangling 'd' would have deleted it)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + const result = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.word.forward"]); // NOT input.delete.word.forward +}); + +it("drx replaces the char and clears pending", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + const result = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(result.actions.some((a) => a.type === "insertText")).toBe(true); +}); + +it("drx then w moves by word (no dangling delete-operator)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + handleNormalKey(state, "x", ev("x"), mockPrompt); + const result = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.word.forward"]); +}); +``` + +Also add a test for the `finishOneShotIfComplete` guard rewrite from Step 2 (a Ctrl+O one-shot with an operator pending must stay in normal mode, not auto-return to insert): + +```ts +it("Ctrl+O one-shot stays in normal mode while an operator is pending", () => { + state.oneShotNormal = true; + const result = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, result); + expect(state.mode).toBe("normal"); +}); +``` + +- [ ] **Step 5: Verify + commit** + +```bash +bun test # PASS — all previously-green tests plus the new pins +git add -A +git commit -m "refactor: consolidate pending state into a discriminated union" +``` + +--- + +## Self-Review + +- **Coverage of the goal.** Tasks 2-9 relocate every symbol in the current `vim.ts`; Task 10 leaves the barrel as pure re-exports; Task 11 mirrors the tests; Task 12 syncs docs. No symbol is orphaned. +- **Firewall + import discipline.** Enforced as Global Constraints and checked mechanically in Task 10 Step 2. Handlers import siblings directly; the barrel never feeds back into the engine. +- **Type/name consistency.** The public surface (8 values + 4 types) is fixed in the "Public surface" section and reproduced identically in every barrel re-export step. `MOTIONS`/`SELECT_MOTIONS`/`DELETE_MOTION` stay internal to `tables.ts`. `finishUndoableChange`/`isInputEmpty` land only in `normal.ts`. +- **Behavior preservation.** Tasks 1-12 are verbatim moves; only Task 0 (tests) and Task 10 (dead-code deletion) change content. Task 13 (Phase 4) is a state-representation change that preserves all tested behavior and deliberately removes untested dangling-state sequences — gated by `bun test` plus new pins. +- **Sequencing.** Tasks 1-12 (file split) → Task 13 (pending union) → then the separate text-object feature. `text.ts` (Task 3), the extracted `VimState`/handler seams, and the `Pending` union (Task 13) are all in place so the feature lands as a clean diff whose only new pending node is `{ kind: "textObject" }`. diff --git a/docs/plans/2026-07-23-vim-text-objects.md b/docs/plans/2026-07-23-vim-text-objects.md new file mode 100644 index 0000000..88974d0 --- /dev/null +++ b/docs/plans/2026-07-23-vim-text-objects.md @@ -0,0 +1,583 @@ +# Vim Text Objects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement word text objects — `diw ciw yiw daw caw yaw` in normal mode and `viw vaw` in visual mode (issue #57). + +**Architecture:** A text object is a pure noun that resolves to a `Range`; operators are verbs that already consume ranges. Add one pure `resolveTextObject` to `text.ts`, one `textObject` node to the `Pending` union, one charwise `applyOperatorToRange` helper (also adopted by the existing `e`/`G` operator branches), and the `i`/`a` dispatch branches to the normal and visual handlers. + +**Tech Stack:** TypeScript (strict), Bun test runner, OpenCode TUI plugin API. + +## Dependencies + +This plan assumes the modularization plan (`2026-07-23-vim-engine-modularization.md`) is **fully applied, including Phase 4**. It relies on: +- `src/vim/text.ts` (home of `charKind`, `isWhitespace`, `endOfWord`, `currentLineRange`). +- `src/vim/types.ts` with the `Pending` discriminated union and non-null `Operator`. +- `src/vim/normal.ts` / `src/vim/visual.ts` and the split `test/vim/*.test.ts` layout. + +Do not start this plan until `bun test` is green on the post-refactor tree. + +## Global Constraints + +- Every step ends with `bun test` green. TDD: write the failing test, watch it fail, implement, watch it pass, commit. +- **Purity.** `resolveTextObject` and the word rules stay pure in `text.ts` and reuse the *same* `charKind`/`isWhitespace` classifier as `w`/`e`. No second word definition. +- **Range convention.** `Range { start, end }` is inclusive; invariant `0 <= start <= end < text.length`. "No object under the cursor" is `null`, never a zero-width sentinel. +- **Scope of `applyOperatorToRange`.** Charwise, offset-based ranges only (`e`, `G`, text objects). Never route linewise (`dd`/`dj`) or host-command motions (`dw`) through it. +- **`i`/`a` stay insert/append** when no operator is pending and not in visual mode. The text-object meaning only applies when `pending.kind === "operator"` (normal) or in visual mode. +- No `any`. Keep each file under 500 lines. Cross-platform. + +## Design decisions (from the council review) + +- `resolveTextObject` takes `count` from the start (mirrors `endOfWord`); omitting it is a later signature break. +- `TextObjectKind` is a plain string union (`"word"`) for now — no roadmap comment in the type. When the first delimiter-carrying kind (quote/bracket) lands, migrate to a tagged union then. +- No lookup table for a single kind; use an inline `key === "w"` check until a second kind exists. +- The `textObject` pending node carries `op: Operator | null` (`null` = visual). This is why Phase 4's union matters: one node serves both contexts. +- Visual `viw` **replaces** the selection with the resolved range (it does not extend from `visualAnchor` like the visual `e` motion). +- `applyOperatorToRange` is cursor-agnostic; text-object callers append `cursorTo(range.start)` (a no-op for forward motions, which is why `e`/`G` don't). +- Word classification reuses `charKind` (JS `\w`), so most non-ASCII letters classify as punctuation — the same behavior `w`/`e` already have. Text objects inherit it deliberately; revisit only if Unicode word support becomes a goal. + +--- + +### Task 1: `Range` + `resolveTextObject` for `iw` (inner word) + +**Files:** +- Modify: `src/vim/types.ts` (add `Range`, `TextObjectVariant`, `TextObjectKind`) +- Modify: `src/vim/text.ts` (add `resolveTextObject`) +- Modify: `test/vim/text.test.ts` + +**Interfaces:** +- Produces: `type Range = { start: number; end: number }`, `type TextObjectVariant = "inner" | "around"`, `type TextObjectKind = "word"`; `resolveTextObject(text, offset, kind, variant, count): Range | null` (this task handles `kind === "word"`, `variant === "inner"`, `count === 1`). + +- [ ] **Step 1: Write the failing tests** + +```ts +import { resolveTextObject } from "../../src/vim/text"; + +describe("resolveTextObject — inner word", () => { + it("word under cursor (start)", () => { + expect(resolveTextObject("hello world", 0, "word", "inner", 1)).toEqual({ start: 0, end: 4 }); + }); + it("word under cursor (mid-word)", () => { + expect(resolveTextObject("hello world", 2, "word", "inner", 1)).toEqual({ start: 0, end: 4 }); + }); + it("second word", () => { + expect(resolveTextObject("hello world", 6, "word", "inner", 1)).toEqual({ start: 6, end: 10 }); + }); + it("cursor on whitespace selects the whitespace run", () => { + expect(resolveTextObject("a b", 1, "word", "inner", 1)).toEqual({ start: 1, end: 3 }); + }); + it("punctuation run is its own object", () => { + expect(resolveTextObject("foo.bar", 3, "word", "inner", 1)).toEqual({ start: 3, end: 3 }); + expect(resolveTextObject("a...b", 1, "word", "inner", 1)).toEqual({ start: 1, end: 3 }); + }); + it("single-char word", () => { + expect(resolveTextObject("a b", 0, "word", "inner", 1)).toEqual({ start: 0, end: 0 }); + }); + it("empty buffer returns null", () => { + expect(resolveTextObject("", 0, "word", "inner", 1)).toBeNull(); + }); + it("word stops at newline", () => { + expect(resolveTextObject("ab\ncd", 0, "word", "inner", 1)).toEqual({ start: 0, end: 1 }); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `bun test test/vim/text.test.ts` +Expected: FAIL (`resolveTextObject` not exported). + +- [ ] **Step 3: Implement inner-word resolution** + +Add to `src/vim/text.ts`, reusing `charKind`: + +```ts +import type { Range, TextObjectKind, TextObjectVariant } from "./types"; + +export function resolveTextObject( + text: string, + offset: number, + kind: TextObjectKind, + variant: TextObjectVariant, + count = 1, +): Range | null { + if (text.length === 0) return null; + const pos = Math.min(Math.max(offset, 0), text.length - 1); + // kind === "word" only for now + const kindAt = charKind(text[pos]); + let start = pos; + let end = pos; + while (start > 0 && charKind(text[start - 1]) === kindAt) start--; + while (end < text.length - 1 && charKind(text[end + 1]) === kindAt) end++; + if (variant === "inner") return { start, end }; + return { start, end }; // "around" — replaced by aroundWord in Task 2 +} +``` + +(`Range`, `TextObjectVariant`, and `TextObjectKind` all land in `types.ts` in this task — pure, dependency-free aliases. Task 2 swaps the `"around"` placeholder for the real `aroundWord`.) + +- [ ] **Step 4: Run tests, verify pass** + +Run: `bun test test/vim/text.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/types.ts src/vim/text.ts test/vim/text.test.ts +git commit -m "feat: resolveTextObject for inner word" +``` + +--- + +### Task 2: `aw` (around word) with trailing-else-leading whitespace + +**Files:** +- Modify: `src/vim/text.ts` (add `aroundWord`) +- Modify: `test/vim/text.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("resolveTextObject — a word", () => { + it("word plus trailing whitespace", () => { + expect(resolveTextObject("hello world", 0, "word", "around", 1)).toEqual({ start: 0, end: 5 }); + }); + it("last word takes leading whitespace when no trailing", () => { + expect(resolveTextObject("hello world", 6, "word", "around", 1)).toEqual({ start: 5, end: 10 }); + }); + it("lone word with neither side falls back to the word", () => { + expect(resolveTextObject("hello", 0, "word", "around", 1)).toEqual({ start: 0, end: 4 }); + }); + it("multiple trailing spaces are all included", () => { + expect(resolveTextObject("a b", 0, "word", "around", 1)).toEqual({ start: 0, end: 3 }); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `bun test test/vim/text.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement `aroundWord`** + +```ts +function aroundWord(text: string, start: number, end: number): Range { + // trailing whitespace first + let e = end; + while (e < text.length - 1 && isWhitespace(text[e + 1]) && text[e + 1] !== "\n") e++; + if (e > end) return { start, end: e }; + // no trailing → take leading whitespace + let s = start; + while (s > 0 && isWhitespace(text[s - 1]) && text[s - 1] !== "\n") s--; + return { start: s, end }; +} +``` + +Then wire it into `resolveTextObject`, replacing the Task 1 `"around"` placeholder (`return { start, end };`) with the explicit branch: + +```ts +if (variant === "inner") return { start, end }; +return aroundWord(text, start, end); +``` + +(Do not leave this implicit. A subagent executor that reads "swaps the placeholder" without the concrete line may leave the placeholder in place; the `daw` test in Task 6 would then silently collapse to `diw`.) + +- [ ] **Step 4: Run tests, verify pass** — `bun test test/vim/text.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/text.ts test/vim/text.test.ts +git commit -m "feat: resolveTextObject for a-word (trailing/leading whitespace)" +``` + +--- + +### Task 3: `count` support + +**Files:** +- Modify: `src/vim/text.ts` +- Modify: `test/vim/text.test.ts` + +`2iw` extends across alternating word/whitespace runs (vim semantics: each run — word, punct, or whitespace — is one object). + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("resolveTextObject — count", () => { + it("2iw spans word + following whitespace", () => { + expect(resolveTextObject("one two", 0, "word", "inner", 2)).toEqual({ start: 0, end: 3 }); + }); + it("3iw spans word + whitespace + word", () => { + expect(resolveTextObject("one two", 0, "word", "inner", 3)).toEqual({ start: 0, end: 6 }); + }); + it("2iw spans a word then a punctuation run (kinds are re-read, not toggled)", () => { + expect(resolveTextObject("foo.bar", 0, "word", "inner", 2)).toEqual({ start: 0, end: 3 }); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail.** `bun test test/vim/text.test.ts` → FAIL. + +- [ ] **Step 3: Implement count** — after finding the first run's `end`, extend `end` forward `count - 1` more runs. At each new boundary **re-read `charKind(text[end + 1])` and extend while the kind stays equal** — do NOT flip a two-state word/space toggle. There are three kinds (`word`/`punct`/`space`), so runs do not simply alternate: `2iw` on `"foo.bar"` is `foo` then the `.` punct run → `{0,3}`, which a boolean flip gets wrong. `count === 1` keeps Task 1/2 behavior. + + **count + `around` ordering:** compute the counted *inner* range first (extend `end` across `count - 1` runs), THEN apply `aroundWord` to the final `[start, end]`. State this explicitly so `d2aw` is deterministic. Note: `count === 1` already satisfies issue #57; `count > 1` for `around` is polish and is left untested, but the ordering must still be pinned in code so the behavior is defined rather than accidental. + +- [ ] **Step 4: Run, verify pass.** `bun test test/vim/text.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/text.ts test/vim/text.test.ts +git commit -m "feat: count support for word text objects" +``` + +--- + +### Task 4: Text-object types + the `textObject` pending node + +**Files:** +- Modify: `src/vim/types.ts` +- Modify: `test/vim/state.test.ts` (createVimState still `{ kind: "none" }`) + +**Interfaces:** +- Produces: the new `Pending` member `{ kind: "textObject"; op: Operator | null; variant: TextObjectVariant }`. (`TextObjectVariant`/`TextObjectKind` were added in Task 1.) + +- [ ] **Step 1: Extend the types** + +```ts +// TextObjectVariant / TextObjectKind already exist from Task 1. +export type Pending = + | { kind: "none" } + | { kind: "operator"; op: Operator } + | { kind: "textObject"; op: Operator | null; variant: TextObjectVariant } // op:null = visual + | { kind: "goto" } + | { kind: "replace" }; +``` + +- [ ] **Step 2: Verify + commit** + +```bash +bun test # PASS (no behavior yet; type only) +git add src/vim/types.ts +git commit -m "feat: add textObject node to the Pending union" +``` + +--- + +### Task 5: Extract `applyOperatorToRange` (adopt in `e`/`G` first) + +**Files:** +- Modify: `src/vim/normal.ts` +- Modify: `test/vim/normal.test.ts` + +Behavior-preserving refactor: fold the duplicated operator-on-range logic from the `e` and `G` branches into one helper. Existing tests must stay green with no output change. + +**Interfaces:** +- Produces: `applyOperatorToRange(state, op, range, text, actions): HandlerResult` (file-local to `normal.ts`). + +- [ ] **Step 1: Write the helper** + +```ts +function applyOperatorToRange( + state: VimState, op: Operator, range: Range, text: string, actions: Action[], +): HandlerResult { + if (op === "y") { + const slice = text.slice(range.start, range.end + 1); + state.yankRegister = slice; + actions.push({ type: "yank", text: slice }); + resetPending(state); + return { consume: true, actions }; + } + actions.push({ type: "deleteRange", start: range.start, end: range.end }); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); +} +``` + +- [ ] **Step 2: Rewrite the `e` and `G` operator branches to call it** + +Capture the operator locally (`const op = state.pending.op`, after the `kind === "operator"` narrowing) and add `Range` to `normal.ts`'s `import type … from "./types"`. Replace the **entire** inline body of each branch — including its `y` special-case, which the helper now owns — with: +```ts +const target = endOfWord(prompt.getPlainText(), offset, n); // (e) — G uses Math.max(0, text.length - 1) +return applyOperatorToRange(state, op, { start: offset, end: target }, prompt.getPlainText(), actions); +``` +`range.start === offset === cursor` here, so no `cursorTo` and the emitted actions are byte-identical to today. + +**`yG` caveat.** `yG` never reaches the `G` sub-branch — it is caught earlier by the generic `pendingOp === "y" && key in MOTIONS` select path (`SELECT_MOTIONS[G]` + `yankSelection`, no snapshot). So the `G` branch you are rewriting only ever runs for `d`/`c`, and `applyOperatorToRange`'s `y`-branch is dead for `G`. Keep that earlier `y`-select branch intact and above the `G` rewrite; do NOT route `yG` through `applyOperatorToRange` (that would swap a selection-yank for a slice-yank and drop the toast). For `e`, by contrast, the helper's `y`-branch legitimately replaces the current `ye` slice-yank (byte-identical). + +- [ ] **Step 3: Run tests, verify no change** + +Run: `bun test test/vim/normal.test.ts` +Expected: PASS, identical assertions (the `de`, `dG`, `ye`, `cG` tests). + +- [ ] **Step 4: Commit** + +```bash +git add src/vim/normal.ts +git commit -m "refactor: extract applyOperatorToRange, adopt in e/G branches" +``` + +--- + +### Task 6: Normal-mode text objects + +**Files:** +- Modify: `src/vim/normal.ts` +- Modify: `test/vim/normal.test.ts` + +**Interfaces:** +- Consumes: `resolveTextObject`, `Range` from `./text`/`./types`; `applyOperatorToRange` (Task 5); `Pending` (Task 4). + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("handleNormalKey — text objects", () => { + const on = (text: string, cur: number) => ({ ...mockPrompt, getPlainText: () => text, getCursorOffset: () => cur }); + + it("diw deletes the inner word as one range", () => { + const p = on("hello world", 2); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("ciw deletes the inner word, enters insert, cursor at word start", () => { + const p = on("hello world", 2); + handleNormalKey(state, "c", ev("c"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursorTos(r.actions)).toEqual([0]); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); + }); + + it("yiw yanks the inner word and moves the cursor to word start", () => { + const p = on("hello world", 2); + handleNormalKey(state, "y", ev("y"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(state.yankRegister).toBe("hello"); + expect(cursorTos(r.actions)).toEqual([0]); + }); + + it("daw deletes word plus trailing whitespace", () => { + const p = on("hello world", 0); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "a", ev("a"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + }); + + it("d2iw deletes across two objects (word + whitespace)", () => { + const p = on("one two", 0); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "2", ev("2"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("di on empty buffer no-ops and clears pending", () => { + const p = on("", 0); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("i with no operator still enters insert", () => { + const r = handleNormalKey(state, "i", ev("i"), mockPrompt); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** — `bun test test/vim/normal.test.ts` → FAIL. + +- [ ] **Step 3: Implement the branches** + +**Placement (critical).** The resolve branch must run BEFORE the standalone `key in MOTIONS` branch and (after Phase 4) the `pending.kind === "operator" && key in MOTIONS` branch — otherwise `w` is consumed as a motion before it can resolve as a text object. Put it immediately AFTER the `const actions: Action[] = []` declaration (so `actions` is in scope) and before the count-accumulation / motion branches. It reuses that `actions`; it does not declare a new one. + +Resolve a pending text object: +```ts +if (state.pending.kind === "textObject") { + const { op, variant } = state.pending; + const text = prompt.getPlainText(); + const n = consumeCount(state); + const range = key === "w" ? resolveTextObject(text, prompt.getCursorOffset(), "word", variant, n) : null; + if (!range) { resetPending(state); return { consume: true, actions }; } + if (op === null) { resetPending(state); return { consume: true, actions }; } // load-bearing narrow (see note); visual op:null never reaches here + const res = applyOperatorToRange(state, op, range, text, actions); + res.actions.push({ type: "cursorTo", offset: range.start }); + return res; +} +``` + +Notes: +- **`actions` scope.** `const actions: Action[] = []` is declared partway down `handleNormalKey`, after the tab / `replace` / `goto` pending checks (which each use their own local `actions`). This branch references `actions`, so it MUST sit after that declaration. Do not place it "beside goto/replace" — those run above the declaration, so referencing `actions` there is a temporal-dead-zone error and will not compile. +- **`op === null` is not dead code.** After `const { op } = state.pending`, `op` is `Operator | null` (the `textObject` node carries `op: Operator | null` so one node serves both normal and visual). The early return narrows `op` to non-null `Operator` for the `applyOperatorToRange` call; removing it is a type error. (Alternative if you dislike the runtime-dead guard: split the union into `{kind:"operatorTextObject"; op: Operator}` + `{kind:"visualTextObject"}` and drop the null case — more honest typing at the cost of one extra member and a second dispatch branch.) +- **`cursorTo(range.start)` is intentional for all three operators, including `y`.** Vim leaves the cursor at the start of the yanked/changed region, so `yiw` with a mid-word cursor moves to the word start; for `ye`/`yG` it is a no-op (`range.start === cursor`). Keep it unconditional. + +Then, inside the operator-pending section (before the standalone `i`/`a` insert entries), intercept the variant prefix: +```ts +if (state.pending.kind === "operator" && (key === "i" || key === "a")) { + state.pending = { kind: "textObject", op: state.pending.op, variant: key === "i" ? "inner" : "around" }; + return { consume: true, actions }; +} +``` +Leave the standalone `i`/`a` insert-entry branches unchanged — they now only run when `pending.kind !== "operator"`. + +- [ ] **Step 4: Run tests, verify pass** — `bun test test/vim/normal.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/normal.ts test/vim/normal.test.ts +git commit -m "feat: normal-mode word text objects (diw/ciw/yiw/daw...)" +``` + +--- + +### Task 7: Visual-mode text objects + +**Files:** +- Modify: `src/vim/visual.ts` +- Modify: `test/vim/visual.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("handleVisualKey — text objects", () => { + const on = (text: string, cur: number) => ({ ...mockPrompt, getPlainText: () => text, getCursorOffset: () => cur }); + beforeEach(() => { state.mode = "visual"; state.visualAnchor = 2; }); + + it("viw selects the inner word range (replaces selection)", () => { + const p = on("hello world", 2); + handleVisualKey(state, "i", ev("i"), p); + const r = handleVisualKey(state, "w", ev("w"), p); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.visualAnchor).toBe(0); + expect(cursorTos(r.actions)).toEqual([4]); + expect(state.mode).toBe("visual"); + }); + + it("vaw selects word plus trailing whitespace", () => { + const p = on("hello world", 0); + state.visualAnchor = 0; + handleVisualKey(state, "a", ev("a"), p); + const r = handleVisualKey(state, "w", ev("w"), p); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail** — `bun test test/vim/visual.test.ts` → FAIL. + +- [ ] **Step 3: Implement the visual branches** + +```ts +if (state.pending.kind === "textObject") { + const { variant } = state.pending; + const range = key === "w" + ? resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), "word", variant, consumeCount(state)) + : null; + resetPending(state); + if (!range) return { consume: true, actions }; + state.visualAnchor = range.start; + actions.push({ type: "selectRange", start: range.start, end: range.end }); + actions.push({ type: "cursorTo", offset: range.end }); + return { consume: true, actions }; +} +if (key === "i" || key === "a") { + state.pending = { kind: "textObject", op: null, variant: key === "i" ? "inner" : "around" }; + return { consume: true, actions }; +} +``` +**Placement (critical).** `w` is a key in `SELECT_MOTIONS` (`input.select.word.forward`), so both branches must go BEFORE the `if (key in SELECT_MOTIONS)` check — otherwise `viw`'s `w` is swallowed by the select-motion dispatch and never resolves. Put them AFTER the `escape` / `v` exit check (so `vi` then `` still exits visual) but BEFORE the `key in SELECT_MOTIONS` branch. "Before the catch-all consume" is too loose — do not rely on it. (`i`/`a` were previously swallowed by the catch-all in visual, so nothing regresses.) Add a `viw` test asserting `selectRange` (not `input.select.word.forward`) to pin the ordering in the suite. + +- [ ] **Step 4: Run, verify pass** — `bun test test/vim/visual.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/visual.ts test/vim/visual.test.ts +git commit -m "feat: visual-mode word text objects (viw/vaw)" +``` + +--- + +### Task 8: Interaction hardening + +**Files:** +- Modify: `test/vim/normal.test.ts`, `test/integration.test.ts` +- Modify: `src/vim/*` only if a test surfaces a gap + +- [ ] **Step 1: Write interaction tests** + +```ts +it("escape after 'di' cancels the pending text object", () => { + const p = { ...mockPrompt, getPlainText: () => "hello", getCursorOffset: () => 0 }; + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + handleNormalKey(state, "escape", ev("escape"), p); + expect(state.pending).toEqual({ kind: "none" }); +}); + +it("Ctrl+O one-shot does not auto-return to insert mid text-object (d then i)", () => { + // pending.kind === "textObject" must count as 'still pending' in finishOneShotIfComplete + state.oneShotNormal = true; + const p = { ...mockPrompt, getPlainText: () => "hello world", getCursorOffset: () => 0 }; + let r = handleNormalKey(state, "d", ev("d"), p); finishOneShotIfComplete(state, r); + r = handleNormalKey(state, "i", ev("i"), p); finishOneShotIfComplete(state, r); + expect(state.mode).toBe("normal"); // not yet returned to insert +}); +``` + +- [ ] **Step 2: Run** — `bun test` → confirm behavior. If the one-shot test fails, verify `finishOneShotIfComplete`'s guard reads `state.pending.kind !== "none"` (it should, after Phase 4). Escape handling: the normal-mode `escape` branch already calls `resetPending`, which clears the `textObject` node. + +- [ ] **Step 3: Full suite** — `bun test` → PASS. + +- [ ] **Step 4: Commit** + +```bash +git add test/ +git commit -m "test: text-object interactions with escape and one-shot mode" +``` + +--- + +### Task 9: Docs + issue + +**Files:** +- Modify: `README.md` (keybinding tables; remove any text-object entry from "Known gaps") +- Modify: `CHANGELOG.md` (`[Unreleased]`) +- Modify: `AGENTS.md` if the resolver/helper is worth a note + +- [ ] **Step 1: README** — add `iw`/`aw` with `d`/`c`/`y`/`v` to the tables. +- [ ] **Step 2: CHANGELOG** — `### Added — Word text objects: diw, ciw, yiw, daw, viw, vaw (#57).` +- [ ] **Step 3: Gate + commit** + +```bash +just check +git add README.md CHANGELOG.md AGENTS.md +git commit -m "docs: document word text objects (#57)" +``` + +--- + +## Self-Review + +- **Spec coverage.** `iw`/`aw` resolution (Tasks 1-3), types + pending node (Task 4), operator helper (Task 5), normal `d/c/y` (Task 6), visual `v` (Task 7), interactions (Task 8), docs (Task 9). The issue's `diw ciw viw daw` are covered by Tasks 6-7. +- **Type/name consistency.** `resolveTextObject(text, offset, kind, variant, count)` is defined once (Task 1) and every call site passes `count` (Tasks 6-7). `Range` is defined in Task 1 and reused by the helper and both handlers. The `textObject` pending node's `op: Operator | null` is set with the operator in normal (Task 6) and `null` in visual (Task 7), and read in exactly those two resolve branches. +- **Reused word definition.** The resolver calls the same `charKind`/`isWhitespace` as `w`/`e`; no divergent classifier. +- **`applyOperatorToRange` scope.** Charwise only; adopted by `e`/`G` behavior-preservingly (Task 5) before text objects use it (Task 6). Visual selection is a separate branch (Task 7), not routed through the helper. +- **`i`/`a` safety.** Intercepted only when `pending.kind === "operator"` (normal) or in visual mode; the standalone insert/append entries are otherwise untouched (Task 6 Step 3, verified by the "i with no operator still enters insert" test). +- **Cursor + undo.** `c`/text-object callers append `cursorTo(range.start)`; `d`/`c` flow through `finishUndoableChange` (via the helper); `y` skips the snapshot. Empty-buffer and escape paths clear pending and emit no destructive actions. diff --git a/docs/test-e2e.md b/docs/test-e2e.md new file mode 100644 index 0000000..f87a6da --- /dev/null +++ b/docs/test-e2e.md @@ -0,0 +1,107 @@ +# Terminal Control CI guards + +Terminal Control checks run automatically on pull requests against `main` and through Actions `workflow_dispatch`. Local runs are on demand only. They use Bun 1.3.2 and pinned `@kitlangton/terminal-control` 1.2.1, with its matching native PTY driver. No Vitest, tmux, observer plugin, or model is needed. + +## Suites and commands + +`bun test`, `npm test`, `just test`, and `just check` retain the existing fast suite, including the existing mocked plugin integration tests. They do not discover the new Terminal Control suites. The guard entry files use `.unit.ts`, `.integration.ts`, and `.e2e.ts` suffixes instead of Bun's automatic `.test.ts` discovery; the commands below pass their filenames explicitly. An explicitly requested guard fails on missing setup rather than silently skipping. + +```sh +just test # existing fast suite; no CLI/network startup +just test-int # harness integration, launcher, and parser checks +just test-e2e # BOTH pinned hosts: v1@1.18.34 then v2@2.0.15 +just test-e2e v1 # optionally select just one host (or v2) + +# Advanced CI/troubleshooting: absolute binary, exact PIN, NEW output directory: +just test-e2e v1 /absolute/opencode-v1 1.18.34 /private/tmp/vimcode-v1-new +just test-e2e v2 /absolute/opencode-v2 2.0.15 /private/tmp/vimcode-v2-new + +# Optional no-plugin control: expected exit 1, literal 0 at word-home: +VIMCODE_E2E_NO_PLUGIN=1 just test-e2e v1 +``` + +### Defaults and optional paths + +`scripts/test-e2e.ts` uses a fresh, canonical `os.tmpdir()` directory outside checkout/config ancestry, prints the run directory and each `receipt.json` path **before execution**, and preserves evidence on failure. Selected hosts run sequentially; any failure makes the final exit nonzero without hiding the other host. The v2 host uses `--standalone` and `cli.json`; v1 uses `tui.json`. + +A binary override is checked first; otherwise an `opencode` on PATH is reused only if its bounded `--version` probe exactly matches the pin. If not, the launcher provisions privately in that run directory, never globally or from `latest`. Provisioning needs network access: v1 downloads the official GitHub release and verifies SHA-256; v2 installs the exact native npm package with scripts disabled and checks manifest name/version. Both verify the resulting binary version. Downloads/installers/probes are bounded, use isolated HOME/XDG/cwd and no inherited credentials or npm configuration, and are not a network sandbox. Per-run installs avoid shared partial-cache/concurrency ownership; delete the printed run directory when evidence is no longer needed. + +Automatic bootstrap is verified for macOS arm64 and GNU/Linux x64 native shapes only (Linux uses baseline builds). Other macOS/Linux architectures require a matching native binary override rather than guessed downloads. Windows is unsupported by this guard. + +Optional, ignored repository `.env` settings are **paths only**: + +```dotenv +VIMCODE_E2E_V1_BIN=/absolute/path/to/opencode-v1 +VIMCODE_E2E_V2_BIN=/absolute/path/to/opencode-v2 +``` + +Only these two keys are recognized. Relative paths resolve against the repository root, then canonicalize; there is no shell expansion, version customization, output/cache-root setting, or dotenv dependency. Process environment settings take precedence. Invalid/empty overrides fail only for the selected host, without falling back or preventing another selected host from running; unused host settings are not validated. `CI` set to a nonempty value and fully explicit advanced invocations ignore the local file. Unknown keys/credentials never become runner settings or child environment; never store secrets for this runner. `.env.*` files are ignored by Git but are not read by the launcher. Missing paths/wrong versions fail actionably, never silently switch binaries. + +The equivalent package commands are `bun --env-file=/dev/null run test:int` and `bun --env-file=/dev/null run test:e2e [v1|v2]` (or `npm run test:e2e -- v1`). Bun **1.3.2 accepts but does not actually suppress autoload with `--no-env-file`**; synthetic fixtures verify that `--env-file=/dev/null` does. The guard recipes/scripts and product-test child use the latter, without enabling just-wide dotenv autoload. Use the flagged Bun command so its outer package runner cannot autoload a local file before the launcher enforces precedence/CI exclusion. + +Advanced output parents must exist and outputs must be empty and outside checkout/OpenCode config ancestry. Existing CI arguments and artifact paths are unchanged; the explicit version must still equal the selected host's pin. + +The suites test different boundaries: + +| Checks | Classification | Entry file | +| --- | --- | --- | +| Prompt/answer parsing, borders, provider metadata, and cursor diagnostics | Unit tests of the harness: pure functions and fixed snapshots | `test/e2e/prompt.unit.ts` | +| Pinned v1 native question setup exports | Unit tests of the harness: synthetic embedded module layouts | `test/e2e/v1-question.unit.ts` | +| SDK requests, timeouts, and owned-process cleanup | Harness integration tests: real SDK and OS processes with a synthetic driver | `test/e2e/driver.integration.ts` | +| Launcher selection, pins, paths, dotenv isolation, bounded probes, and aggregate/negative exits | Harness integration tests: synthetic settings and OS child processes, no host downloads | `test/e2e/launcher.integration.ts` | +| Installed plugin behavior in real OpenCode | Product E2E tests: real host, PTY, packaged vimcode, and keyboard input | `test/e2e/opencode.e2e.ts` | + +`just test-int` groups the SDK/process integration checks and supporting parser units under the **Harness integration tests** CI job. The parser units stay outside ordinary product-test discovery as part of the on-demand harness suite. Lifecycle checks cover stalled readiness, launch/shutdown responses, SDK abort, keyboard type/press requests through the real failure/finally path, unresolved cleanup retries, and driver-override rejection. They do not exercise vimcode or OpenCode. Keyboard raw-write timeouts use the same failure/finally cleanup checks as type/press. A synthetic native-startup-exit case verifies that the body is never entered, the scenario stays failed, no retry occurs, and native exit status plus only the current scenario's error log are retained. + +Five product E2E scenarios run against both host generations: + +- Cold package-cache installation and exact insertion of `cold package loaded`. +- `Escape`, `0`, `w`, `i`, `X` produces exactly `one Xtwo three`. +- `Escape`, `0`, `w`, `ci"`, `new` produces exactly `say "new" now`, including an intermediate empty-quote check. +- Multiline `dG` leaves exactly `alpha` plus an empty line; one `u` restores exactly `alpha`, `bravo`, `charlie`. + +- A question answer containing `hello world`, followed by Escape and a single raw PTY write of `a` plus bracketed paste `X`, must display exactly `hello worldX`. This regression was demonstrated red (`hello worlXd`) with the paste-ordering fix removed on both hosts. + +Ctrl+G opens a synthetic question through test-only setup plugins. v2 uses the public session/form API. v1 has no question-create endpoint, so its server fixture listens for the synthetic session and calls the pinned native `Question` service. `v1-question.ts` resolves only the embedded runtime/store/question exports, validates their module layout and service shape, and records their paths/hashes in the receipt; it does not copy or patch host code. Importing the runtime is deferred until the session event to avoid bootstrap re-entry. A private `.npmrc` bounds v1's optional config dependency install when the registry is unavailable; the plain fixtures have no npm imports. A strict visible choice checkpoint guards Enter before opening the v1 custom-answer editor. Neither fixture reads or edits widget state. Assertions require the complete bordered one-line editor layout, and fixtures stay outside the vimcode tarball. + +## PR checks and host pins + +`.github/workflows/test.yml` keeps the existing `check` job and adds: + +- **Harness integration tests**, running `just test-int` on Ubuntu 24.04. +- **E2E tests (v1)**, running `just test-e2e` on pinned OpenCode **1.18.34**. +- **E2E tests (v2)**, running `just test-e2e` on pinned OpenCode **2.0.15**. + +The matrix has `fail-fast: false`, so one host failure does not hide the other. Failures remain failing checks; there is no `continue-on-error`. The workflow has read-only repository permissions, no `pull_request_target`, and no provider secrets. Jobs install tools independently and have bounded timeouts. PR updates cancel superseded workflow runs. + +The v1 job downloads the official Linux x64 baseline release asset and verifies its pinned SHA-256 before extraction. The v2 job installs the official `@opencode/cli-linux-x64-baseline@2.0.15` native package with scripts disabled and verifies its manifest. Both find exactly one executable and check its reported version. Hosts install into separate `RUNNER_TEMP` directories, never globally or from `latest`. The baseline x64 builds avoid relying on optional newer CPU instructions. + +Each E2E matrix job also runs a no-plugin negative control. It accepts only exit 1 with the exact recorded `word-home` failure (`one two three0`), the expected scenario failure, matching host/version, observed provider-disabled home state, and no cleanup errors. A missing binary, setup failure, or unrelated failure cannot count as a successful control. This case never sends Enter. The positive multiline case guards Enter with observed Vim cursor transitions. + +After the first successful GitHub run, an owner must make these check names required in branch protection if they should block merge. Workflow configuration alone does not change repository protection. No actual GitHub run or required-status configuration has been performed by this change. + +## Isolation and failure evidence + +Each E2E invocation packs the current source with `npm pack --ignore-scripts`, installs an npm-source tarball spec through the real host cache, and verifies the cached manifest and all source files byte-for-byte against the unpacked artifact. Tests and launchers are not packaged. `receipt.json` records host/binary/version, platform, packaged driver path, tarball SHA-256, per-file hashes, checkpoints, observed provider-disabled state per scenario, and cleanup errors. + +Driver and host receive isolated HOME, all XDG config/data/cache/state directories, a sandbox cwd, and no inherited provider credentials or config overrides. PATH contains only selected Bun/npm/Node tool directories, system directories, and first-priority clipboard stubs. Update checks are disabled. The isolated `opencode.json` sets `enabled_providers: []`, explicitly excluding even free OpenCode Zen models. Before any scenario inputs, the actual TUI must show the strict bordered home prompt with `Build · No provider selected Connect a provider`; only then does the receipt claim `providersDisabled: true` and record that scenario's observation. Provider-free empty prompts support `Ask` or the host's `Ask anything` placeholder. If the observed v1 connection dialog overlays startup, a bounded Escape dismisses it; no Enter or approval is sent to that dialog. No `models` CLI probe is run: v2 can start an unowned service daemon through that route. Clipboard writes are discarded and reads return nothing. Inputs are synthetic; no model submission, permission approval, or real clipboard access is tested. This is environment/config isolation, not an OS/network sandbox: package/cache startup may access the network. + +On failure, Actions uploads only the guard logs, synthetic receipts, scenario checkpoint `.txt` files, and available `failure.txt`, `failure.json`, `failure-status.json`, and `failure.termctrl` evidence, named per host and retained for seven days. v2 runs at error log level; local `host-error.log` retains only current-scenario error lines from the isolated host log. It is not uploaded automatically. Cache, config, home, history, and package trees are not uploaded. Local artifacts stay in the requested output directory. Recordings may contain typed data: do not adapt these guards to real credentials or private prompts and upload the result. + +A nonempty inherited `TERMCTRL_BINARY` is rejected and recorded during setup. The SDK resolves that override in the Bun parent before applying the isolated driver environment; the guard supports only the pinned packaged driver. + +## Pinned private lifecycle boundary + +`test/e2e/driver.ts` uses the SDK 1.2.1 private constructor, `ready`, `child`, and `abort`, because public `make()` hides ownership until readiness and `close()` has no forced-reap fallback. Manifest version, installed JS SHA-256, source shape, and runtime guards fail fast if that boundary changes. Upgrade it only with explicit review and renewed lifecycle probes; do not patch the installed dependency. + +Ownership is retained before waiting for hello. Cooperative stop/shutdown failures remain reported even when forced cleanup succeeds. Unresolved handles survive cleanup retries, block later launches, and never become a claimed successful reap. + +For forced cleanup on macOS/Linux, the adapter freezes the exact owned driver PID before reading `ps` parent/group identities. Each direct PTY child must lead its own process group. It kills those groups before killing the driver, waits for the driver's `close` event, and verifies the groups disappear, including zombies. SDK abort is intercepted before it can lose group identities. There is no process-name matching. This follows pinned native [`Session::terminate`/`Drop`](https://github.com/anomalyco/terminal-control/blob/v1.2.1/src/session.rs) and [`ManagedSession::stop_worker`/`Drop`](https://github.com/anomalyco/terminal-control/blob/v1.2.1/src/driver.rs): killing the driver alone bypasses its destructors. Unexpected ownership or unavailable process inspection is a cleanup failure. External driver SIGKILL/OS failure and descendants escaping their PTY group are outside this test-only guarantee. + +## Oracle and validation limits + +Both pinned hosts expose the same bordered home prompt. Assertions compare every rendered prompt line exactly, including empty lines and internal whitespace, not a substring anywhere on screen. Cases use short ASCII, unwrapped lines without trailing spaces. Terminal padding cannot distinguish trailing buffer spaces; these tests do not establish hidden-buffer state, Unicode/display-cell behavior, selection, routes beyond home and the single-line v1/v2 question cases, full v2 parity, or all-version compatibility. + +Cursor-only SDK captures may be stale. Word/quote placement is proven by exact editing effects; cursor metadata is diagnostic there. The multiline Enter safety guard requires observed cursor transitions. Captures must be settled and remain unchanged for a 250 ms stability window after each checkpoint starts, with bounded 5-second keyboard sends/editing captures, a 30-second startup observation deadline, and a 10 ms polling cadence. The 240-second outer scenario timeout leaves room for the longest sequence's bounded operations, failure evidence, and finally cleanup; Bun's timeout alone cannot reject a stalled SDK request. This avoids Escape/printable-key coalescing without unconditional per-key sleeps. + +Both pinned hosts (including the provider-free four-scenario run and no-plugin negative controls) and the harness checks (including stalled-send failure/finally/reap probes) were validated on macOS arm64 with Bun 1.3.2. The default no-argument run passed v1 via PATH but v2 automatic npm provisioning was blocked by registry `ENOTCONN` in this environment, so bare/no-override eight-case success is not claimed. A no-argument run with the optional v2 binary path override passed all eight cases; selectors, advanced paths, failure propagation, and the official v1 macOS bootstrap were also checked. The first Ubuntu CI run passed the full v1 suite and harness checks. v2 passed four scenarios, including question-answer paste, but one private server exited before readiness in the quote scenario. A full Ubuntu 24.04 x64 Docker reproduction and 30 additional startup/shutdown cycles passed; the original native exit cause remains unconfirmed. Failure diagnostics now retain native exit status and the isolated current-scenario host error log. No automatic startup retries or weakened editing assertions were added. Windows is not supported by this PTY guard; the existing fast suite remains unchanged. diff --git a/justfile b/justfile index 793b4f4..2627e3f 100644 --- a/justfile +++ b/justfile @@ -21,10 +21,30 @@ 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}}" + +# Harness integration checks and parser helpers; local runs are on demand. +test-int: + bun --env-file=/dev/null run test:int + +# Both pinned hosts by default; optionally select one or supply all explicit paths. +test-e2e host="" binary="" version="" output="": + bun --env-file=/dev/null run test:e2e "{{host}}" "{{binary}}" "{{version}}" "{{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/package.json b/package.json index d4d5381..cc011a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vimcode", - "version": "0.16.0", + "version": "0.20.0", "description": "Vim keybindings for the OpenCode prompt", "author": "Ori Bar-ilan", "license": "MIT", @@ -23,13 +23,17 @@ ], "scripts": { "test": "bun test", + "test:int": "bun --env-file=/dev/null test ./test/e2e/prompt.unit.ts ./test/e2e/v1-question.unit.ts ./test/e2e/driver.integration.ts ./test/e2e/launcher.integration.ts", + "test:e2e": "bun --env-file=/dev/null scripts/test-e2e.ts", "typecheck": "tsc --noEmit", "check": "biome check .", "fix": "biome check --write ." }, "devDependencies": { "@biomejs/biome": "2.4.15", + "@kitlangton/terminal-control": "1.2.1", "@opencode-ai/plugin": "^1.15.4", + "@opentui/keymap": "0.5.10", "tsx": "^4.22.1" } } diff --git a/scripts/dev2.ts b/scripts/dev2.ts new file mode 100644 index 0000000..7077ea4 --- /dev/null +++ b/scripts/dev2.ts @@ -0,0 +1,56 @@ +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", + "session.child.first": ["down", "down", "j"], + "composer.subagent.up": ["up", "h", "k"], + "composer.subagent.down": ["down", "j", "l"], + }, + }), +}); + +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/scripts/test-e2e.ts b/scripts/test-e2e.ts new file mode 100644 index 0000000..ad784e8 --- /dev/null +++ b/scripts/test-e2e.ts @@ -0,0 +1,315 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseEnv } from "node:util"; + +export const pins = { v1: "1.18.34", v2: "2.0.15" } as const; +export type Host = keyof typeof pins; +const overrideKeys = { v1: "VIMCODE_E2E_V1_BIN", v2: "VIMCODE_E2E_V2_BIN" } as const; +type Environment = Record; +export type Selection = { hosts: Host[]; explicit?: { binary: string; version: string; output: string } }; +const root = realpathSync(fileURLToPath(new URL("../", import.meta.url))); +const configNames = [ + ".git", + ".opencode", + "opencode.json", + "opencode.jsonc", + "tui.json", + "tui.jsonc", + "cli.json", + "cli.jsonc", +]; + +export function selectHosts(args: string[]): Selection { + const values = [...args]; + while (values.at(-1) === "") values.pop(); + if (!values.length) return { hosts: ["v1", "v2"] }; + const [host, binary, version, output] = values; + if ((host !== "v1" && host !== "v2") || (values.length !== 1 && values.length !== 4)) + throw new Error( + "Use just test-e2e [v1|v2] or just test-e2e HOST /absolute/BINARY PINNED_VERSION /absolute/NEW_OUTPUT", + ); + if (values.length === 1) return { hosts: [host] }; + if (!isAbsolute(binary) || !isAbsolute(output) || version !== pins[host]) + throw new Error(`Explicit ${host} requires absolute binary/output paths and pinned version ${pins[host]}`); + return { hosts: [host], explicit: { binary, version, output } }; +} + +// parseEnv is data-only: neither shell expansion nor unknown keys reach child environments. +export function binarySettings(env: Environment, fileText = ""): Partial> { + const local = env.CI ? {} : parseEnv(fileText); + const settings: Partial> = {}; + for (const host of ["v1", "v2"] as const) { + const key = overrideKeys[host]; + const value = env[key] ?? local[key]; + if (value !== undefined) settings[host] = value; + } + return settings; +} + +export function isolatedToolEnv(directory: string): Record { + for (const name of ["home", "config", "data", "cache", "state", "cwd"]) + mkdirSync(join(directory, name), { recursive: true }); + const tools = [process.execPath, Bun.which("npm"), Bun.which("node")] + .filter((path): path is string => path !== null) + .map((path) => dirname(path)); + return { + PATH: [...new Set([...tools, "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(":"), + HOME: join(directory, "home"), + XDG_CONFIG_HOME: join(directory, "config"), + XDG_DATA_HOME: join(directory, "data"), + XDG_CACHE_HOME: join(directory, "cache"), + XDG_STATE_HOME: join(directory, "state"), + OPENCODE_DISABLE_AUTOUPDATE: "1", + }; +} + +function outsideConfig(path: string) { + for (let ancestor = path; ; ancestor = dirname(ancestor)) { + if (configNames.some((name) => existsSync(join(ancestor, name)))) + throw new Error(`E2E output must be outside project/config ancestry: ${ancestor}`); + if (dirname(ancestor) === ancestor) break; + } +} + +export function freshRunRoot(): string { + const temporary = realpathSync(tmpdir()); + outsideConfig(temporary); + return realpathSync(mkdtempSync(join(temporary, "vimcode-e2e-"))); +} + +export function outputPath(path: string): string { + if (!isAbsolute(path)) throw new Error("E2E output must be absolute"); + const canonical = existsSync(path) + ? realpathSync(path) + : join(realpathSync(dirname(path)), path.slice(dirname(path).length + 1)); + outsideConfig(canonical); + if (existsSync(canonical) && (!statSync(canonical).isDirectory() || readdirSync(canonical).length)) + throw new Error(`E2E output must be a new or empty directory: ${canonical}`); + return canonical; +} + +function command(binary: string, args: string[], directory: string, env: Record, timeout = 10_000) { + return execFileSync(binary, args, { + cwd: directory, + env, + encoding: "utf8", + timeout, + killSignal: "SIGKILL", + maxBuffer: 2 * 1024 * 1024, + }); +} + +function versionOf(binary: string, directory: string, env: Record): string { + return command(binary, ["--version"], directory, env) + .trim() + .replace(/^opencode v/, ""); +} + +export function verifiedBinary( + path: string, + host: Host, + directory: string, + env: Record, + base = root, +): string { + // Validate only when this host runs: an unused host override must not block selection. + if (!path.trim() || /[\r\n\0]/.test(path)) throw new Error(`${overrideKeys[host]} must be a nonempty binary path`); + let binary: string; + try { + binary = realpathSync(resolve(base, path)); + if (!statSync(binary).isFile()) throw new Error("not a file"); + } catch { + throw new Error(`${overrideKeys[host]}: binary path does not exist or is not a file: ${path}`); + } + const actual = versionOf(binary, directory, env); + if (actual !== pins[host]) + throw new Error(`Expected OpenCode ${host} ${pins[host]}, got ${JSON.stringify(actual)} at ${binary}`); + return binary; +} + +export function nativeSource( + host: Host, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): { package: string } | { asset: string; sha256: string } { + const target = `${platform}-${arch}`; + if (target !== "darwin-arm64" && target !== "linux-x64") + throw new Error( + `No verified bootstrap for ${target}; set ${overrideKeys[host]} to a native OpenCode ${pins[host]} binary`, + ); + if (host === "v2") return { package: `@opencode/cli-${target === "linux-x64" ? "linux-x64-baseline" : target}` }; + return target === "darwin-arm64" + ? { asset: "opencode-darwin-arm64.zip", sha256: "8522b70f545184b3a8d97c5ca4f814093b2476d72aebfda8c48bcd072ec31d1b" } + : { + asset: "opencode-linux-x64-baseline.tar.gz", + sha256: "24b0d458d21ef548b2752166303defcf7f4945b049fb4876ab78dfaf86d81b27", + }; +} + +function provision(host: Host, directory: string, env: Record): string { + const source = nativeSource(host); + const native = join(directory, "native"); + mkdirSync(native); + if ("package" in source) { + // npm refuses using the same config file for both scopes; neither may read host credentials. + const userConfig = join(directory, "user.npmrc"); + const globalConfig = join(directory, "global.npmrc"); + writeFileSync(userConfig, ""); + writeFileSync(globalConfig, ""); + command( + "npm", + [ + "install", + "--prefix", + native, + "--no-save", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--registry=https://registry.npmjs.org", + `--userconfig=${userConfig}`, + `--globalconfig=${globalConfig}`, + "--fetch-retries=1", + "--fetch-timeout=45000", + `${source.package}@${pins[host]}`, + ], + directory, + env, + 120_000, + ); + const packageRoot = join(native, "node_modules", source.package); + const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + name: string; + version: string; + }; + if (manifest.name !== source.package || manifest.version !== pins[host]) + throw new Error("Unexpected native host package identity"); + return verifiedBinary(join(packageRoot, "bin/opencode"), host, directory, env); + } + const archive = join(directory, source.asset); + command( + "curl", + [ + "--fail", + "--location", + "--connect-timeout", + "10", + "--max-time", + "90", + "--output", + archive, + `https://github.com/anomalyco/opencode/releases/download/v${pins[host]}/${source.asset}`, + ], + directory, + env, + 100_000, + ); + if (createHash("sha256").update(readFileSync(archive)).digest("hex") !== source.sha256) + throw new Error(`Official ${source.asset} SHA-256 mismatch; refusing extraction`); + const zip = source.asset.endsWith(".zip"); + const contents = command(zip ? "unzip" : "tar", zip ? ["-Z1", archive] : ["-tzf", archive], directory, env).trim(); + if (contents !== "opencode") throw new Error("Expected exactly one native opencode archive entry"); + command(zip ? "unzip" : "tar", zip ? ["-q", archive, "-d", native] : ["-xzf", archive, "-C", native], directory, env); + return verifiedBinary(join(native, "opencode"), host, directory, env); +} + +export function resolveBinary( + host: Host, + override: string | undefined, + directory: string, + env: Record, +): string { + if (override !== undefined) return verifiedBinary(override, host, directory, env); + const installed = Bun.which("opencode"); + if (installed) { + try { + if (versionOf(installed, directory, env) === pins[host]) return realpathSync(installed); + } catch { + // A broken/unmatched PATH host is not an override; bootstrap the exact pin instead. + } + } + process.stdout.write(`Bootstrapping private OpenCode ${host}@${pins[host]} (network required)\n`); + try { + return provision(host, directory, env); + } catch (error) { + throw new Error( + `Could not bootstrap ${host}@${pins[host]}. Set ${overrideKeys[host]} in .env or the process environment to an existing pinned native binary. ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} + +export async function runHosts(hosts: Host[], execute: (host: Host) => Promise): Promise { + let status = 0; + for (const host of hosts) if ((await execute(host)) !== 0) status = 1; + return status; +} + +async function main() { + if (!["darwin", "linux"].includes(process.platform)) + throw new Error("Terminal Control E2E requires macOS or GNU/Linux"); + const selection = selectHosts(process.argv.slice(2)); + // CI and fully explicit invocations never read a local file. + const file = join(root, ".env"); + const settings = selection.explicit + ? {} + : binarySettings(process.env, !process.env.CI && existsSync(file) ? readFileSync(file, "utf8") : ""); + const runRoot = freshRunRoot(); + process.stdout.write(`E2E run directory: ${runRoot}\n`); + const outputs = Object.fromEntries( + selection.hosts.map((host) => [host, outputPath(selection.explicit?.output ?? join(runRoot, host))]), + ); + for (const host of selection.hosts) + process.stdout.write(`${host}@${pins[host]} receipt: ${join(outputs[host], "receipt.json")}\n`); + return runHosts(selection.hosts, async (host) => { + try { + const directory = join(runRoot, `${host}-tools`); + const env = isolatedToolEnv(directory); + const binary = resolveBinary(host, selection.explicit?.binary ?? settings[host], directory, env); + process.stdout.write(`${host} binary: ${binary}\n`); + const child = Bun.spawn([process.execPath, "--env-file=/dev/null", "test", "./test/e2e/opencode.e2e.ts"], { + cwd: root, + env: { + ...env, + VIMCODE_E2E_HOST: host, + VIMCODE_E2E_BIN: binary, + VIMCODE_E2E_VERSION: pins[host], + VIMCODE_E2E_OUTPUT: outputs[host], + VIMCODE_E2E_NO_PLUGIN: process.env.VIMCODE_E2E_NO_PLUGIN === "1" ? "1" : "0", + ...(process.env.TERMCTRL_BINARY ? { TERMCTRL_BINARY: process.env.TERMCTRL_BINARY } : {}), + }, + stdout: "inherit", + stderr: "inherit", + stdin: "ignore", + }); + return await child.exited; + } catch (error) { + const message = `${host} launcher failed: ${error instanceof Error ? error.message : String(error)}`; + writeFileSync(join(runRoot, `${host}-launcher-error.txt`), `${message}\n`); + process.stderr.write(`${message}\n`); + return 1; + } + }); +} + +if (import.meta.main) { + try { + process.exitCode = await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/src/editing.ts b/src/editing.ts new file mode 100644 index 0000000..3bb0a61 --- /dev/null +++ b/src/editing.ts @@ -0,0 +1,28 @@ +import { createVimState, type VimState } from "./vim"; + +export type EditingContext = { + state: VimState; + undoSnapshots: Array<{ text: string; cursor: number }>; + deferredActions: Array<() => void>; + visualEditorOwner?: unknown; + leaderPending: boolean; + leaderTimer?: ReturnType; +}; + +export function createEditingContext(): EditingContext { + return { state: createVimState(), undoSnapshots: [], deferredActions: [], leaderPending: false }; +} + +// The host assigns this trait to question textareas on both v1 and v2. +// It is not sufficient alone: the controller also checks question ownership. +export function hasAnswerFocus(renderer: { + currentFocusedEditor?: unknown; + currentFocusedRenderable?: unknown; +}): boolean { + const editor = renderer.currentFocusedEditor; + return ( + !!editor && + editor === renderer.currentFocusedRenderable && + (editor as { traits?: { status?: unknown } }).traits?.status === "ANSWER" + ); +} diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..30456ce --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,28 @@ +type VisualSelectionEditor = { + cursorOffset: number; + selectionBg?: Color; + selectionFg?: Color; + editBuffer?: { getTextRange(start: number, end: number): string }; + editorView?: { + setSelection(start: number, end: number, bg?: Color, fg?: Color): void; + resetSelection(): 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 || !editor.editBuffer?.getTextRange) return; + const start = Math.min(anchor, editor.cursorOffset); + 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 7fe99d4..598dac5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ -import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; +import type { TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"; import { writeClipboard } from "./clipboard"; +import { createEditingContext, type EditingContext, hasAnswerFocus } from "./editing"; +import { selectVisualCharacterRange } from "./editor"; import { findMatchingLeader, type KeyLike, leaderChar } from "./leader"; +import { createV2Facade, type V2Context } from "./v2"; import { checkForUpdate } from "./version"; import { type Action, - createVimState, finishOneShotIfComplete, handleInsertKey, handleNormalKey, @@ -13,10 +15,13 @@ import { translateKey, } from "./vim"; -const plugin: TuiPluginModule = { +const plugin = { id: "vimcode", - tui: async (api, options) => { - const state = createVimState(); + tui: async (api: TuiPluginApi, options?: Record) => { + const mainContext = createEditingContext(); + const state = mainContext.state; + let answerContexts = new WeakMap(); + let displayedContext = mainContext; const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; const leaderKeys = resolveLeaderKeys(); @@ -38,18 +43,17 @@ const plugin: TuiPluginModule = { api.ui?.toast?.({ message: "Vim mode disabled (use /vim to re-enable)", variant: "info", duration: 3000 }); } - // Track whether the previous key was the leader, so the follow-up - // key also passes through to OpenCode's leader system. - let leaderPending = false; - let leaderTimer: ReturnType | null = null; + // Keep handles for disposal even after an answer editor loses focus. + const leaderTimers = new Set>(); // Track pending permissions/questions from child sessions via events. // permission()/question() only covers one session ID, but subagent // prompts live on child IDs. Events fire globally; we aggregate by root. const pendingChildPrompts = new Map(); + const pendingChildQuestions = new Map(); // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - function trackPromptEvent(event: any, delta: number) { + function trackPromptEvent(event: any, delta: number, question = false) { const sessionID = event?.properties?.sessionID ?? event?.sessionID; if (!sessionID) return; const session = api.state?.session?.get?.(sessionID); @@ -57,6 +61,11 @@ const plugin: TuiPluginModule = { const count = (pendingChildPrompts.get(rootId) ?? 0) + delta; if (count <= 0) pendingChildPrompts.delete(rootId); else pendingChildPrompts.set(rootId, count); + if (question) { + const questions = (pendingChildQuestions.get(rootId) ?? 0) + delta; + if (questions <= 0) pendingChildQuestions.delete(rootId); + else pendingChildQuestions.set(rootId, questions); + } } // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API @@ -64,14 +73,20 @@ const plugin: TuiPluginModule = { // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API const unsubPermsReplied = api.event?.on?.("permission.replied", (e: any) => trackPromptEvent(e, -1)); // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1)); + const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1, true)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1, true)); + // Dismissing a question emits question.rejected, not question.replied. + // Without this the +1 from question.asked never balances and the plugin + // stays stuck passing every key through to the host. // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1)); + const unsubQuestRejected = api.event?.on?.("question.rejected", (e: any) => trackPromptEvent(e, -1, true)); api.lifecycle?.onDispose?.(() => { unsubPermsAsked?.(); unsubPermsReplied?.(); unsubQuestAsked?.(); unsubQuestReplied?.(); + unsubQuestRejected?.(); }); function hasActivePrompts(sid: string): boolean { @@ -82,10 +97,121 @@ const plugin: TuiPluginModule = { return (pendingChildPrompts.get(sid) ?? 0) > 0; } - // Snapshots for single-step undo of vim changes. - // 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 disposed = false; + api.lifecycle?.onDispose?.(() => { + disposed = true; + }); + + type HostKey = Parameters[0]; + type HostPaste = Parameters; + type ConsumeEvent = { preventDefault(): void; stopPropagation(): void }; + type HostKeyEvent = HostKey & ConsumeEvent; + type HostPasteEvent = ConsumeEvent & { bytes: HostPaste[0]; metadata?: HostPaste[1] }; + type HostInput = { type: "key"; value: HostKey } | { type: "paste"; value: HostPaste }; + // Local host declarations omit the inherited EventEmitter methods. + const keyInput = api.renderer?.keyInput as + | (TuiPluginApi["renderer"]["keyInput"] & { + prependListener(event: "keypress", listener: (key: HostKeyEvent) => void): void; + prependListener(event: "paste", listener: (paste: HostPasteEvent) => void): void; + off(event: "keypress", listener: (key: HostKeyEvent) => void): void; + off(event: "paste", listener: (paste: HostPasteEvent) => void): void; + }) + | undefined; + const canReplayInput = + !!keyInput?.processParsedKey && !!keyInput?.processPaste && !!keyInput?.prependListener && !!keyInput?.off; + let paletteHandoff: { source: unknown; target?: unknown; inputs: HostInput[]; deadline: number } | undefined; + let paletteTimer: ReturnType | undefined; + let replayingInput = false; + + function replayPaletteInput() { + paletteTimer = undefined; + const handoff = paletteHandoff; + if (!handoff || disposed) return; + const renderer = api.renderer; + const target = renderer?.currentFocusedEditor; + if (!handoff.target) { + if ( + api.ui?.dialog?.open && + target && + target !== handoff.source && + target === renderer.currentFocusedRenderable + ) { + handoff.target = target; + } else if (Date.now() < handoff.deadline) { + paletteTimer = setTimeout(replayPaletteInput, 0); + return; + } else { + paletteHandoff = undefined; + api.ui?.toast?.({ message: "Command palette did not take focus", variant: "warning", duration: 2000 }); + return; + } + } + if (!api.ui?.dialog?.open || target !== handoff.target || target !== renderer.currentFocusedRenderable) { + paletteHandoff = undefined; + return; + } + const input = handoff.inputs.shift(); + if (!input) { + paletteHandoff = undefined; + return; + } + // Reconstruct native events one per turn so dialog mounting and focus + // changes settle, while preserving paste metadata and fresh flags. + replayingInput = true; + try { + if (input.type === "key") keyInput?.processParsedKey(input.value); + else keyInput?.processPaste(...input.value); + } finally { + replayingInput = false; + } + if (disposed || !handoff.inputs.length || !api.ui?.dialog?.open) paletteHandoff = undefined; + else paletteTimer = setTimeout(replayPaletteInput, 0); + } + + function hostMode(): string | undefined { + return ( + (api.keymap as typeof api.keymap & { mode?: { current(): string } }).mode?.current() ?? api.mode?.current?.() + ); + } + + function answerOwnsFocus(): boolean { + const renderer = api.renderer; + const route = api.route.current; + if (!renderer || !hasAnswerFocus(renderer) || api.ui?.dialog?.open || route.name !== "session") return false; + const mode = hostMode(); + if (mode) return mode === "question" || mode === "form"; + const sid = route.params?.sessionID; + if (typeof sid !== "string") return false; + const rootID = api.state.session.get?.(sid)?.parentID ?? sid; + return !!api.state.session.question(sid)?.length || !!pendingChildQuestions.get(rootID); + } + + function answerContextForFocus(): EditingContext | undefined { + if (state.disabled || !answerOwnsFocus()) return; + const editor = api.renderer.currentFocusedEditor; + if (!editor) return; + let context = answerContexts.get(editor); + if (!context) { + context = createEditingContext(); + answerContexts.set(editor, context); + } + return context; + } + + function mainPromptBlocked(): boolean { + const route = api.route.current; + const mode = hostMode(); + if (api.ui?.dialog?.open || (mode && mode !== "base" && mode !== "autocomplete")) return true; + if (route.name !== "session") return false; + const sid = route.params?.sessionID; + return typeof sid === "string" && (!!api.state.session.get?.(sid)?.parentID || hasActivePrompts(sid)); + } + + function showContext(context: EditingContext) { + if (displayedContext === context) return; + displayedContext = context; + applyActions([{ type: "mode", mode: context.state.oneShotNormal ? "(insert)" : context.state.mode }], context); + } const prompt = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", @@ -116,20 +242,108 @@ const plugin: TuiPluginModule = { ); } - function applyActions(actions: Action[]) { + function applyActions(actions: Action[], context = mainContext) { + const state = context.state; + const editor = api.renderer?.currentFocusedEditor; + const route = api.route.current; + const routeName = route.name; + const sessionID = "params" in route ? route.params?.sessionID : undefined; + const isAnswer = context !== mainContext; + const stillOwnsEditor = () => { + const renderer = api.renderer; + const currentRoute = api.route.current; + return ( + !disposed && + !mainContext.state.disabled && + !api.ui?.dialog?.open && + renderer?.currentFocusedEditor === editor && + (renderer?.currentFocusedRenderable === undefined || renderer.currentFocusedRenderable === editor) && + currentRoute.name === routeName && + ("params" in currentRoute ? currentRoute.params?.sessionID : undefined) === sessionID && + (isAnswer + ? answerContextForFocus() === context + : !mainPromptBlocked() && !answerContextForFocus() && !hasAnswerFocus(renderer ?? {})) + ); + }; + function deferAction(run: () => void) { + if (!isAnswer) { + setTimeout(run, 0); + return; + } + context.deferredActions.push(run); + setTimeout(() => { + const index = context.deferredActions.indexOf(run); + if (index < 0) return; + context.deferredActions.splice(index, 1); + run(); + }, 0); + } let keepUndoSnapshotForBatch = false; for (const action of actions) { // Buffer-modifying actions (cmd, insertText) clear the undo stack, // unless this batch includes a saveUndoSnapshot (which sets // keepUndoSnapshotForBatch to preserve the stack). if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { - undoSnapshots = []; + context.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 : editor; + const command = + isAnswer && action.cmd === "prompt.history.previous" + ? "input.move.up" + : isAnswer && action.cmd === "prompt.history.next" + ? "input.move.down" + : action.cmd; + if (isAnswer && action.cmd === "prompt.paste") { + if (state.yankRegister) editor?.insertText?.(state.yankRegister); + break; + } + const globalCommand = command === "command.palette.show"; + if (isAnswer && globalCommand && canReplayInput) { + paletteHandoff = { source: editor, inputs: [], deadline: Date.now() + 1000 }; + } + if ( + isAnswer && + (command === "input.submit" || command.startsWith("prompt.") || command.startsWith("session.")) + ) + break; + deferAction(() => { + if ( + (globalCommand ? disposed : !stillOwnsEditor()) || + (visualEditor && + (api.ui?.dialog?.open || + api.renderer?.currentFocusedEditor !== visualEditor || + api.renderer?.currentFocusedRenderable !== visualEditor)) + ) + return; + const dispatched = api.keymap.dispatchCommand(command); + if (isAnswer && globalCommand && paletteHandoff) { + if (dispatched?.ok === false) paletteHandoff = undefined; + else paletteTimer = setTimeout(replayPaletteInput, 0); + } + if ( + dispatched?.ok && + visualEditor && + visualAnchor !== undefined && + state.mode === "visual" && + state.visualAnchor === visualAnchor && + context.visualEditorOwner === visualEditor && + !api.ui?.dialog?.open && + api.renderer?.currentFocusedEditor === visualEditor && + api.renderer?.currentFocusedRenderable === visualEditor + ) { + selectVisualCharacterRange(visualEditor, visualAnchor); + } + }); break; + } case "mode": + context.visualEditorOwner = action.mode === "visual" ? (context.visualEditorOwner ?? editor) : undefined; if (modeIndicator === "toast") { const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); api.ui?.toast?.({ @@ -154,8 +368,8 @@ const plugin: TuiPluginModule = { break; case "yankSelection": { // Deferred so it runs after any preceding select commands - setTimeout(() => { - const editor = api.renderer?.currentFocusedEditor; + deferAction(() => { + if (!stillOwnsEditor()) return; const text = editor?.editorView?.getSelectedText?.() ?? ""; if (text) { state.yankRegister = text; @@ -167,7 +381,7 @@ const plugin: TuiPluginModule = { }); } editor?.editorView?.resetSelection?.(); - }, 0); + }); break; } case "clearSelection": @@ -176,7 +390,7 @@ const plugin: TuiPluginModule = { case "deleteRange": { const editor = api.renderer?.currentFocusedEditor; const eb = editor?.editBuffer; - if (eb?.deleteRange) { + if (eb?.deleteRange && editor) { const text = editor.plainText ?? ""; const [sl, sc] = offsetToLineCol(text, action.start); const [el, ec] = offsetToLineCol(text, action.end + 1); @@ -187,7 +401,7 @@ const plugin: TuiPluginModule = { case "saveUndoSnapshot": { const editor = api.renderer?.currentFocusedEditor; if (editor) { - undoSnapshots.push({ + context.undoSnapshots.push({ text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0, }); @@ -196,7 +410,7 @@ const plugin: TuiPluginModule = { break; } case "undo": { - const undoSnapshot = undoSnapshots.pop(); + const undoSnapshot = context.undoSnapshots.pop(); if (undoSnapshot) { const editor = api.renderer?.currentFocusedEditor; const eb = editor?.editBuffer; @@ -205,7 +419,9 @@ const plugin: TuiPluginModule = { editor.cursorOffset = undoSnapshot.cursor; } } else { - setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); + deferAction(() => { + if (stillOwnsEditor()) api.keymap.dispatchCommand("input.undo"); + }); } break; } @@ -227,9 +443,24 @@ const plugin: TuiPluginModule = { function syncCursorStyle() { const editor = api.renderer?.currentFocusedEditor; - if (!editor) return; + if ( + !editor || + api.ui?.dialog?.open || + (api.renderer.currentFocusedRenderable !== undefined && api.renderer.currentFocusedRenderable !== editor) + ) + return; + if (state.disabled) { + if (answerOwnsFocus() || (!hasAnswerFocus(api.renderer) && !mainPromptBlocked())) { + editor.cursorStyle = { style: "line", blinking: true }; + } + return; + } + const answer = answerContextForFocus(); + if (!answer && (hasAnswerFocus(api.renderer) || mainPromptBlocked())) return; + const context = answer ?? mainContext; + showContext(context); editor.cursorStyle = { - style: state.mode === "insert" ? "line" : "block", + style: context.state.mode === "insert" ? "line" : "block", blinking: true, }; } @@ -241,7 +472,7 @@ const plugin: TuiPluginModule = { const cursorInterval = setInterval(syncCursorStyle, 100); api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval)); api.lifecycle?.onDispose?.(() => { - if (leaderTimer) clearTimeout(leaderTimer); + for (const timer of leaderTimers) clearTimeout(timer); }); if (options?.updateCheck !== false) { @@ -263,9 +494,22 @@ const plugin: TuiPluginModule = { slashName: cmd, run: exitRun, })); + const submitRun = async () => { + setTimeout(() => api.keymap.dispatchCommand("input.submit"), 0); + }; + const submitCommands = ["w", "write"].map((cmd) => ({ + name: `vimcode.${cmd}`, + title: `:${cmd}`, + category: "Vim", + namespace: "palette", + desc: "Send prompt", + slashName: cmd, + run: submitRun, + })); api.keymap.registerLayer?.({ commands: [ ...exitCommands, + ...submitCommands, { name: "vimcode.vim", title: ":vim", @@ -275,6 +519,7 @@ const plugin: TuiPluginModule = { slashName: "vim", run: async () => { const result = toggleVimMode(state); + answerContexts = new WeakMap(); await api.kv?.set?.("vimcode.disabled", state.disabled); applyActions(result.actions); }, @@ -288,15 +533,25 @@ const plugin: TuiPluginModule = { if (ctx.event.eventType === "release") return; // If vim mode is disabled, pass all keys through unmodified. - if (state.disabled) return; + if (mainContext.state.disabled || disposed) return; + // Terminal input can contain several keys before timers run. A queued + // palette must transfer ownership before the following key is routed. + const previousAnswer = answerContextForFocus(); + if (previousAnswer) for (const action of previousAnswer.deferredActions.splice(0)) action(); + const answer = answerContextForFocus(); + const context = answer ?? mainContext; + const state = context.state; + if (hasAnswerFocus(api.renderer ?? {}) && !answer) return; - // Pass through when any overlay owns the keyboard: dialogs (command - // palette, session list, etc.), question prompts, or permission prompts. + // Question choices, permissions and unrelated dialogs remain host-owned. if (api.ui?.dialog?.open) return; const route = api.route.current; if (route.name === "session") { const sid = route.params?.sessionID; - if (sid && hasActivePrompts(sid)) { + // Child sessions have no editable prompt. Let host navigation own + // the keys without changing the mode restored in the parent (#79). + if (!answer && typeof sid === "string" && api.state?.session?.get?.(sid)?.parentID) return; + if (!answer && typeof sid === "string" && hasActivePrompts(sid)) { // Consume the leader key so dispatchLayers() doesn't // match it as a leader token, which would enter pending- // sequence state instead of typing a space. @@ -310,10 +565,18 @@ 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 mode = hostMode(); + if (mode === "autocomplete" && state.mode !== "insert") return; + showContext(context); + // Answer commit and field navigation belong to the host question layer. + if (answer && (ctx.event.name === "return" || ctx.event.name === "tab")) 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. - if (state.mode === "insert") { + if (!answer && state.mode === "insert") { if (ctx.event.name === "escape") { const r = api.keymap.dispatchCommand("prompt.autocomplete.hide"); if (r.ok) { @@ -334,21 +597,33 @@ const plugin: TuiPluginModule = { // In normal/visual mode, let the leader key and its follow-up // pass through so OpenCode's leader bindings work. - if (leaderKeys.length > 0 && state.mode !== "insert") { - if (leaderPending) { - leaderPending = false; - if (leaderTimer) clearTimeout(leaderTimer); + if (leaderKeys.length > 0 && state.mode !== "insert" && state.pending.kind !== "find") { + if (context.leaderPending) { + context.leaderPending = false; + if (context.leaderTimer) { + clearTimeout(context.leaderTimer); + leaderTimers.delete(context.leaderTimer); + } return; } if (findMatchingLeader(ctx.event, leaderKeys)) { - leaderPending = true; - leaderTimer = setTimeout(() => { - leaderPending = false; + context.leaderPending = true; + context.leaderTimer = setTimeout(() => { + context.leaderPending = false; + if (context.leaderTimer) leaderTimers.delete(context.leaderTimer); }, 2000); + leaderTimers.add(context.leaderTimer); return; } } + // 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 !== context.visualEditorOwner) { + context.visualEditorOwner = editor; + state.visualAnchor = editor.cursorOffset; + } const handlerMode = state.mode; const result = state.mode === "insert" @@ -379,12 +654,49 @@ const plugin: TuiPluginModule = { } if (consume) ctx.consume(); - applyActions(actions); + applyActions(actions, context); }, { priority: 10_000 }, ); + if (canReplayInput && keyInput) { + const capturePaletteKey = (key: HostKeyEvent) => { + if (!paletteHandoff || replayingInput || key.eventType === "release") return; + key.preventDefault(); + key.stopPropagation(); + paletteHandoff.inputs.push({ type: "key", value: key }); + }; + const capturePalettePaste = (paste: HostPasteEvent) => { + // Native paste bypasses the key intercept, so settle answer motions first. + if (!replayingInput && !disposed) { + const answer = answerContextForFocus(); + if (answer) for (const action of answer.deferredActions.splice(0)) action(); + } + if (!paletteHandoff || replayingInput) return; + paste.preventDefault(); + paste.stopPropagation(); + paletteHandoff.inputs.push({ type: "paste", value: [paste.bytes, paste.metadata] }); + }; + keyInput.prependListener("keypress", capturePaletteKey); + keyInput.prependListener("paste", capturePalettePaste); + api.lifecycle?.onDispose?.(() => { + keyInput.off("keypress", capturePaletteKey); + keyInput.off("paste", capturePalettePaste); + if (paletteTimer) clearTimeout(paletteTimer); + paletteHandoff = undefined; + }); + } + }, + 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..b690ac4 --- /dev/null +++ b/src/v2.ts @@ -0,0 +1,250 @@ +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { hasAnswerFocus } from "./editing"; +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; + processParsedKey?(key: RawKeyEvent): boolean; + processPaste?(...paste: Parameters): 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: "" }, + }); + // 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 + ? ["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. + const answer = mode === "form" && hasAnswerFocus(context.renderer); + if (mode === "form" && !answer && !effectiveDisabled) { + 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" && !answer) 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" && !(mode === "form" && hasAnswerFocus(context.renderer)); + }, + }, + }, + route: { + get current() { + const route = context.ui.router.current(); + return { name: route.type, params: { sessionID: route.sessionID } }; + }, + }, + state: { + session: { + get: (sessionID: string) => { + const rootID = context.data.session.root(sessionID); + return { parentID: rootID === sessionID ? undefined : rootID }; + }, + 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" + ? 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); + }); + }, + }, + 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/src/version.ts b/src/version.ts index 8d3288e..1c817de 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,5 +1,5 @@ // Keep in sync with package.json on each release. -export const VERSION = "0.16.0"; +export const VERSION = "0.20.0"; // GitHub API returns fresh content immediately; raw.githubusercontent.com // is CDN-cached for up to 5 minutes which delays update detection. diff --git a/src/vim.ts b/src/vim.ts deleted file mode 100644 index 99c20b3..0000000 --- a/src/vim.ts +++ /dev/null @@ -1,827 +0,0 @@ -export type Mode = "normal" | "insert" | "visual" | "(insert)"; -export type Operator = "d" | "c" | "y" | null; -type FindDirection = "forward" | "backward"; - -export type Action = - | { type: "cmd"; cmd: string } - | { type: "mode"; mode: Mode } - | { type: "toast"; message: string; duration?: number } - | { type: "yank"; text: string } - | { type: "insertText"; text: string } - | { type: "yankSelection" } - | { type: "clearSelection" } - | { type: "deleteRange"; start: number; end: number } - | { type: "saveUndoSnapshot" } - | { type: "undo" } - | { type: "cursorTo"; offset: number } - | { type: "selectRange"; start: number; end: number }; - -export type HandlerResult = { - consume: boolean; - actions: Action[]; -}; - -export type VimState = { - mode: Mode; - pendingOp: Operator; - pendingChar: "r" | "g" | "f" | "F" | null; - count: number; - yankRegister: string; - lastFind: { char: string; direction: FindDirection } | null; - oneShotNormal: boolean; - disabled: boolean; - visualAnchor?: number; -}; - -export type KeyEvent = { - name: string; - shift?: boolean; - ctrl?: boolean; - meta?: boolean; - super?: boolean; - eventType?: string; -}; - -export type PromptAccess = { - getLine: (n: number) => string; - getLineCount: () => number; - getCursorLine: () => number; - getCursorOffset: () => number; - getPlainText: () => string; -}; - -export const MOTIONS: Record = { - h: "input.move.left", - l: "input.move.right", - j: "input.move.down", - k: "input.move.up", - w: "input.word.forward", - b: "input.word.backward", - "0": "input.line.home", - "^": "input.line.home", - $: "input.line.end", - G: "input.buffer.end", -}; - -export const SELECT_MOTIONS: Record = { - h: "input.select.left", - l: "input.select.right", - j: "input.select.down", - k: "input.select.up", - w: "input.select.word.forward", - b: "input.select.word.backward", - "0": "input.select.line.home", - "^": "input.select.line.home", - $: "input.select.line.end", - G: "input.select.buffer.end", -}; - -const DELETE_MOTION: Record = { - w: "input.delete.word.forward", - b: "input.delete.word.backward", - $: "input.delete.to.line.end", - "0": "input.delete.to.line.start", - "^": "input.delete.to.line.start", - h: "input.backspace", - l: "input.delete", -}; - -const PASS: HandlerResult = { consume: false, actions: [] }; -const _CONSUME: HandlerResult = { consume: true, actions: [] }; - -export function createVimState(): VimState { - return { - mode: "insert", - pendingOp: null, - pendingChar: null, - count: 0, - yankRegister: "", - lastFind: null, - oneShotNormal: false, - disabled: false, - }; -} - -export function toggleVimMode(state: VimState): HandlerResult { - state.disabled = !state.disabled; - if (state.disabled) { - // Reset to clean insert mode so cursor style updates and no stale - // pending state carries over when re-enabled. - state.mode = "insert"; - state.pendingOp = null; - state.pendingChar = null; - state.count = 0; - state.oneShotNormal = false; - return { - consume: true, - actions: [ - { type: "toast", message: "Vim mode disabled" }, - { type: "mode", mode: "insert" }, - ], - }; - } - return { consume: true, actions: [{ type: "toast", message: "Vim mode enabled" }] }; -} - -export function endOfWord(text: string, offset: number, count = 1): number { - const len = text.length; - if (len === 0) return 0; - let pos = offset; - for (let step = 0; step < count; step++) { - // If inside a word/punct run, advance one to start looking for next end - if (pos < len - 1 && charKind(text[pos]) !== "space") { - pos++; - } - // Skip whitespace - while (pos < len && isWhitespace(text[pos])) pos++; - if (pos >= len) return len - 1; - // Find end of current word class run - const kind = charKind(text[pos]); - while (pos + 1 < len && charKind(text[pos + 1]) === kind) pos++; - } - return Math.min(pos, len - 1); -} - -export function findChar( - text: string, - offset: number, - char: string, - direction: FindDirection, - count = 1, -): number | null { - if (char.length !== 1 || text.length === 0) return null; - - const safeOffset = Math.min(Math.max(offset, 0), text.length); - const lineStart = text.lastIndexOf("\n", safeOffset - 1) + 1; - const newline = text.indexOf("\n", safeOffset); - const lineEnd = newline === -1 ? text.length : newline; - let target = safeOffset; - - for (let step = 0; step < count; step++) { - target = - direction === "forward" ? text.indexOf(char, target + 1) : text.lastIndexOf(char, target - 1); - if (target < lineStart || target >= lineEnd) return null; - } - - return target; -} - -export function firstNonBlank(text: string, offset: number): number { - if (text.length === 0) return 0; - - const safeOffset = Math.min(Math.max(offset, 0), text.length); - const lineStart = text.lastIndexOf("\n", safeOffset - 1) + 1; - const newline = text.indexOf("\n", safeOffset); - const lineEnd = newline === -1 ? text.length : newline; - - for (let target = lineStart; target < lineEnd; target++) { - if (text[target] !== " " && text[target] !== "\t") return target; - } - return lineStart; -} - -function isWhitespace(ch: string): boolean { - return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"; -} - -function charKind(ch: string): "word" | "punct" | "space" { - if (isWhitespace(ch)) return "space"; - if (/\w/.test(ch)) return "word"; - return "punct"; -} - -export function translateKey(ev: KeyEvent): string { - let key = ev.name; - if (ev.shift && ev.name.length === 1) { - if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase(); - else if (ev.name === "4") key = "$"; - else if (ev.name === "6") key = "^"; - else if (ev.name === "[") key = "{"; - else if (ev.name === "]") key = "}"; - } - return key; -} - -export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { - if (ev.name === "escape") { - state.mode = "normal"; - const actions: Action[] = []; - // Vim moves cursor one left when leaving insert mode, - // unless at position 0 or start of line. - const offset = prompt.getCursorOffset(); - if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { - actions.push({ type: "cursorTo", offset: offset - 1 }); - } - actions.push({ type: "mode", mode: "normal" }); - return { consume: true, actions }; - } - if (ev.name === "return" && ev.ctrl) { - return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; - } - if (ev.name === "return") { - return { consume: true, actions: [{ type: "cmd", cmd: "input.newline" }] }; - } - if (ev.name === "tab") { - return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; - } - if (ev.name === "o" && ev.ctrl) { - state.mode = "normal"; - state.oneShotNormal = true; - return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; - } - return PASS; -} - -export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { - if (ev.meta || ev.super) return PASS; - if (ev.ctrl) { - if (ev.name === "r") { - resetPending(state); - return { consume: true, actions: [{ type: "cmd", cmd: "input.redo" }] }; - } - return PASS; - } - - if (ev.name === "escape") { - if (state.oneShotNormal) { - state.oneShotNormal = false; - state.mode = "insert"; - resetPending(state); - return { consume: true, actions: [{ type: "mode", mode: "insert" }] }; - } - resetPending(state); - return PASS; - } - - // Pending character argument (r{char}) - if (state.pendingChar === "r") { - const n = consumeCount(state); - const actions: Action[] = []; - pushN(actions, "input.delete", n); - actions.push({ type: "insertText", text: key.repeat(n) }); - state.pendingChar = null; - return finishUndoableChange(actions); - } - - // Pending character motion (f{char}, F{char}) - if (state.pendingChar === "f" || state.pendingChar === "F") { - const pendingChar = state.pendingChar; - state.pendingChar = null; - return applyNormalFind( - state, - prompt, - characterArgument(key), - pendingChar === "f" ? "forward" : "backward", - true, - ); - } - - // Pending g prefix (gg, ge, etc.) - if (state.pendingChar === "g") { - state.pendingChar = null; - const actions: Action[] = []; - if (key === "g") { - consumeCount(state); - actions.push({ type: "cursorTo", offset: 0 }); - } else { - resetPending(state); - } - return { consume: true, actions }; - } - - if (ev.name === "tab") return PASS; - - // Everything below is consumed - const actions: Action[] = []; - - if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { - state.count = state.count * 10 + parseInt(key, 10); - return { consume: true, actions }; - } - - if (ev.name === "return") { - actions.push({ type: "cmd", cmd: "input.submit" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === ":") { - actions.push({ type: "cmd", cmd: "command.palette.show" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "/") { - actions.push({ type: "cmd", cmd: "session.timeline" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "[") { - actions.push({ type: "cmd", cmd: "session.half.page.up" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "]") { - actions.push({ type: "cmd", cmd: "session.half.page.down" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "{") { - actions.push({ type: "cmd", cmd: "session.message.previous" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "}") { - actions.push({ type: "cmd", cmd: "session.message.next" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "p") { - if (state.yankRegister) actions.push({ type: "yank", text: state.yankRegister }); - actions.push({ type: "cmd", cmd: "prompt.paste" }); - resetPending(state); - return finishUndoableChange(actions); - } - - if (key === "X") { - pushN(actions, "input.backspace", consumeCount(state)); - return finishUndoableChange(actions); - } - - if (key === "J") { - const n = consumeCount(state); - for (let i = 0; i < n; i++) { - actions.push({ type: "cmd", cmd: "input.line.end" }); - actions.push({ type: "cmd", cmd: "input.delete" }); - } - return finishUndoableChange(actions); - } - - // Operators: d, c, y - if (key === "d" || key === "c" || key === "y") { - if (state.pendingOp === key) { - const n = consumeCount(state); - if (key === "y") { - const cursorLine = prompt.getCursorLine(); - const lines: string[] = []; - for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i)); - const text = `${lines.join("\n")}\n`; - state.yankRegister = text; - actions.push({ type: "yank", text }); - actions.push({ type: "toast", message: `${n} line${n > 1 ? "s" : ""} yanked`, duration: 1000 }); - resetPending(state); - } else { - pushN(actions, "input.delete.line", n); - if (key === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - return { consume: true, actions }; - } - state.pendingOp = key; - return { consume: true, actions }; - } - - if (key === "D") { - actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); - resetPending(state); - return finishUndoableChange(actions); - } - - if (key === "C") { - actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); - enterInsert(state, actions); - return finishUndoableChange(actions); - } - - if (key === "f" || key === "F") { - state.pendingChar = key; - return { consume: true, actions }; - } - - if (key === ";" || key === ",") { - if (!state.lastFind) { - resetPending(state); - return { consume: true, actions }; - } - const direction = key === ";" ? state.lastFind.direction : oppositeDirection(state.lastFind.direction); - return applyNormalFind(state, prompt, state.lastFind.char, direction, false); - } - - // Pending operator + e (end-of-word needs special handling) - if (state.pendingOp && key === "e") { - const n = consumeCount(state); - const offset = prompt.getCursorOffset(); - const target = endOfWord(prompt.getPlainText(), offset, n); - if (state.pendingOp === "y") { - const text = prompt.getPlainText().slice(offset, target + 1); - state.yankRegister = text; - actions.push({ type: "yank", text }); - resetPending(state); - return { consume: true, actions }; - } - actions.push({ type: "deleteRange", start: offset, end: target }); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - // Pending operator + motion - if (state.pendingOp && key in MOTIONS) { - const n = consumeCount(state); - - if (state.pendingOp === "y") { - const selectCmd = SELECT_MOTIONS[key]; - if (selectCmd) { - pushN(actions, selectCmd, n); - actions.push({ type: "yankSelection" }); - } - resetPending(state); - return { consume: true, actions }; - } - - if (key === "j") { - pushN(actions, "input.delete.line", n + 1); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - if (key === "k") { - pushN(actions, "input.move.up", n); - pushN(actions, "input.delete.line", n + 1); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - if (key === "G") { - consumeCount(state); - const offset = prompt.getCursorOffset(); - const text = prompt.getPlainText(); - actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - const deleteCmd = DELETE_MOTION[key]; - if (deleteCmd) { - pushN(actions, deleteCmd, n); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - resetPending(state); - return { consume: true, actions }; - } - - // Standalone e (end-of-word) - if (key === "e") { - const n = consumeCount(state); - const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); - actions.push({ type: "cursorTo", offset: target }); - return { consume: true, actions }; - } - - // Standalone motions - if (key in MOTIONS) { - const n = consumeCount(state); - if ((key === "j" || key === "k") && isInputEmpty(prompt)) { - const cmd = key === "k" ? "prompt.history.previous" : "prompt.history.next"; - pushN(actions, cmd, n); - return { consume: true, actions }; - } - pushN(actions, MOTIONS[key], n); - return { consume: true, actions }; - } - - // g prefix — wait for second keypress - if (key === "g") { - state.pendingChar = "g"; - return { consume: true, actions }; - } - - if (key === "x") { - pushN(actions, "input.delete", consumeCount(state)); - return finishUndoableChange(actions); - } - - if (key === "r") { - state.pendingChar = "r"; - return { consume: true, actions }; - } - - if (key === "u") { - actions.push({ type: "undo" }); - resetPending(state); - return { consume: true, actions }; - } - - // Visual mode entry - if (key === "V") { - const range = currentLineRange(prompt.getPlainText(), prompt.getCursorOffset()); - state.mode = "visual"; - state.visualAnchor = prompt.getCursorOffset(); - state.oneShotNormal = false; - resetPending(state); - return { - consume: true, - actions: [ - { type: "selectRange", start: range.start, end: range.end }, - { type: "mode", mode: "visual" }, - ], - }; - } - - if (key === "v") { - state.mode = "visual"; - state.visualAnchor = prompt.getCursorOffset(); - state.oneShotNormal = false; - resetPending(state); - return { consume: true, actions: [{ type: "mode", mode: "visual" }] }; - } - - // Insert entries - if (key === "i") { - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "I") { - actions.push({ type: "cursorTo", offset: firstNonBlank(prompt.getPlainText(), prompt.getCursorOffset()) }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "a") { - actions.push({ type: "cmd", cmd: "input.move.right" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "A") { - actions.push({ type: "cmd", cmd: "input.line.end" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "o") { - actions.push({ type: "cmd", cmd: "input.line.end" }); - actions.push({ type: "cmd", cmd: "input.newline" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "O") { - actions.push({ type: "cmd", cmd: "input.line.home" }); - actions.push({ type: "cmd", cmd: "input.newline" }); - actions.push({ type: "cmd", cmd: "input.move.up" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - // Unbound key — already consumed - return { consume: true, actions }; -} - -export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { - if (ev.meta || ev.super) return PASS; - if (ev.ctrl) return PASS; - - const actions: Action[] = []; - - if (state.pendingChar === "f" || state.pendingChar === "F") { - if (ev.name === "escape") { - state.pendingChar = null; - } else { - const pendingChar = state.pendingChar; - state.pendingChar = null; - return applyVisualFind( - state, - prompt, - characterArgument(key), - pendingChar === "f" ? "forward" : "backward", - true, - ); - } - } - - // Pending g prefix in visual mode - if (state.pendingChar === "g") { - state.pendingChar = null; - if (key === "g") { - actions.push({ type: "cmd", cmd: "input.select.buffer.home" }); - state.count = 0; - return { consume: true, actions }; - } - // Unknown g-combo or escape — fall through to normal visual handling - } - - // Count accumulation - if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { - state.count = state.count * 10 + parseInt(key, 10); - return { consume: true, actions }; - } - - // Exit visual mode - if (ev.name === "escape" || key === "v") { - exitVisual(state, actions); - return { consume: true, actions }; - } - - // Operators act on selection - if (key === "d" || key === "x") { - actions.push({ type: "cmd", cmd: "input.backspace" }); - enterNormal(state, actions); - return { consume: true, actions }; - } - - if (key === "c") { - actions.push({ type: "cmd", cmd: "input.backspace" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "y") { - actions.push({ type: "yankSelection" }); - enterNormal(state, actions); - return { consume: true, actions }; - } - - // e — extend selection to end of word (custom, not a host command) - if (key === "e") { - const n = consumeCount(state); - const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); - actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); - actions.push({ type: "cursorTo", offset: target }); - return { consume: true, actions }; - } - - if (key === "f" || key === "F") { - state.pendingChar = key; - return { consume: true, actions }; - } - - if (key === ";" || key === ",") { - if (!state.lastFind) { - state.count = 0; - return { consume: true, actions }; - } - const direction = key === ";" ? state.lastFind.direction : oppositeDirection(state.lastFind.direction); - return applyVisualFind(state, prompt, state.lastFind.char, direction, false); - } - - // Motions extend selection - if (key in SELECT_MOTIONS) { - pushN(actions, SELECT_MOTIONS[key], consumeCount(state)); - return { consume: true, actions }; - } - - // g prefix — wait for second keypress - if (key === "g") { - state.pendingChar = "g"; - return { consume: true, actions }; - } - - // Unbound key — consume to prevent typing - return { consume: true, actions }; -} - -// ── Helpers ────────────────────────────────────────────────── - -export function finishOneShotIfComplete(state: VimState, result: HandlerResult): void { - if (!state.oneShotNormal) return; - if (!result.consume) return; - if (state.pendingOp !== null || state.pendingChar !== null || state.count > 0) return; - const alreadyEnteringInsert = result.actions.some((a) => a.type === "mode" && a.mode === "insert"); - if (alreadyEnteringInsert) { - state.oneShotNormal = false; - return; - } - state.oneShotNormal = false; - state.mode = "insert"; - result.actions.push({ type: "mode", mode: "insert" }); -} - -function resetPending(state: VimState) { - state.pendingOp = null; - state.pendingChar = null; - state.count = 0; -} - -function characterArgument(key: string): string { - if (key === "space") return " "; - if (key === "tab") return "\t"; - return key; -} - -function oppositeDirection(direction: FindDirection): FindDirection { - return direction === "forward" ? "backward" : "forward"; -} - -function applyNormalFind( - state: VimState, - prompt: PromptAccess, - char: string, - direction: FindDirection, - remember: boolean, -): HandlerResult { - if (remember) state.lastFind = { char, direction }; - - const offset = prompt.getCursorOffset(); - const target = findChar(prompt.getPlainText(), offset, char, direction, consumeCount(state)); - const actions: Action[] = []; - if (target === null) { - resetPending(state); - return { consume: true, actions }; - } - - const range = - direction === "forward" ? { start: offset, end: target } : { start: target + 1, end: offset }; - if (state.pendingOp === "y") { - const text = prompt.getPlainText().slice(range.start, range.end + 1); - state.yankRegister = text; - actions.push({ type: "yank", text }); - resetPending(state); - return { consume: true, actions }; - } - - if (state.pendingOp === "d" || state.pendingOp === "c") { - const operator = state.pendingOp; - actions.push({ type: "deleteRange", ...range }); - if (operator === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - actions.push({ type: "cursorTo", offset: target }); - return { consume: true, actions }; -} - -function applyVisualFind( - state: VimState, - prompt: PromptAccess, - char: string, - direction: FindDirection, - remember: boolean, -): HandlerResult { - if (remember) state.lastFind = { char, direction }; - - const target = findChar(prompt.getPlainText(), prompt.getCursorOffset(), char, direction, consumeCount(state)); - const actions: Action[] = []; - if (target !== null) { - actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); - actions.push({ type: "cursorTo", offset: target }); - } - return { consume: true, actions }; -} - -function finishUndoableChange(actions: Action[]): HandlerResult { - return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; -} - -function currentLineRange(text: string, offset: number): { start: number; end: number } { - if (text.length === 0) return { start: 0, end: 0 }; - const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); - const start = text.lastIndexOf("\n", safeOffset - 1) + 1; - const newline = text.indexOf("\n", safeOffset); - return { start, end: newline === -1 ? text.length - 1 : newline }; -} - -function consumeCount(state: VimState): number { - const n = state.count || 1; - state.count = 0; - return n; -} - -function enterInsert(state: VimState, actions: Action[]) { - resetPending(state); - state.mode = "insert"; - state.oneShotNormal = false; - actions.push({ type: "mode", mode: "insert" }); -} - -function enterNormal(state: VimState, actions: Action[]) { - state.mode = "normal"; - state.count = 0; - state.oneShotNormal = false; - actions.push({ type: "mode", mode: "normal" }); -} - -function exitVisual(state: VimState, actions: Action[]) { - actions.push({ type: "clearSelection" }); - enterNormal(state, actions); -} - -function pushN(actions: Action[], cmd: string, n: number) { - for (let i = 0; i < n; i++) actions.push({ type: "cmd", cmd }); -} - -function isInputEmpty(prompt: PromptAccess): boolean { - return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; -} diff --git a/src/vim/index.ts b/src/vim/index.ts new file mode 100644 index 0000000..90e5ed4 --- /dev/null +++ b/src/vim/index.ts @@ -0,0 +1,7 @@ +export { handleInsertKey } from "./insert"; +export { handleNormalKey } from "./normal"; +export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; +export { endOfWord } from "./text"; +export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; +export { translateKey } from "./util"; +export { handleVisualKey } from "./visual"; diff --git a/src/vim/insert.ts b/src/vim/insert.ts new file mode 100644 index 0000000..31acf32 --- /dev/null +++ b/src/vim/insert.ts @@ -0,0 +1,32 @@ +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS } from "./util"; + +export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.name === "escape") { + state.mode = "normal"; + const actions: Action[] = []; + // Vim moves cursor one left when leaving insert mode, + // unless at position 0 or start of line. + const offset = prompt.getCursorOffset(); + if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { + actions.push({ type: "cursorTo", offset: offset - 1 }); + } + actions.push({ type: "mode", mode: "normal" }); + return { consume: true, actions }; + } + if (ev.name === "return" && ev.ctrl) { + return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; + } + if (ev.name === "return") { + return { consume: true, actions: [{ type: "cmd", cmd: "input.newline" }] }; + } + if (ev.name === "tab") { + return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; + } + if (ev.name === "o" && ev.ctrl) { + state.mode = "normal"; + state.oneShotNormal = true; + return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; + } + return PASS; +} diff --git a/src/vim/normal.ts b/src/vim/normal.ts new file mode 100644 index 0000000..7b43eb1 --- /dev/null +++ b/src/vim/normal.ts @@ -0,0 +1,426 @@ +import { consumeCount, enterInsert, resetPending } from "./state"; +import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; +import { currentLineRange, endOfWord, findChar, firstNonBlank } from "./text"; +import { resolveTextObject } from "./textobject"; +import type { Action, FindDirection, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types"; +import { characterArgument, oppositeDirection, PASS, pushN } from "./util"; + +export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.meta || ev.super) return PASS; + if (ev.ctrl) { + if (ev.name === "r") { + resetPending(state); + return { consume: true, actions: [{ type: "cmd", cmd: "input.redo" }] }; + } + return PASS; + } + + // Arrow keys are host navigation, not vim motions. Pass them through so + // OpenCode handles them (e.g. exiting the subagent view from normal mode, + // issue #63). Consuming them here would swallow the key and trap the user. + if (ev.name === "up" || ev.name === "down" || ev.name === "left" || ev.name === "right") { + return PASS; + } + + if (ev.name === "escape") { + if (state.oneShotNormal) { + state.oneShotNormal = false; + state.mode = "insert"; + resetPending(state); + return { consume: true, actions: [{ type: "mode", mode: "insert" }] }; + } + resetPending(state); + return PASS; + } + + if (state.pending.kind === "find") { + return applyNormalFind(state, prompt, characterArgument(key), state.pending.direction, true); + } + + // Pending character argument (r{char}) + if (state.pending.kind === "replace") { + const n = consumeCount(state); + const actions: Action[] = []; + pushN(actions, "input.delete", n); + actions.push({ type: "insertText", text: key.repeat(n) }); + state.pending = { kind: "none" }; + return finishUndoableChange(actions); + } + + // Pending g prefix (gg, ge, etc.) + if (state.pending.kind === "goto") { + state.pending = { kind: "none" }; + const actions: Action[] = []; + if (key === "g") { + consumeCount(state); + actions.push({ type: "cursorTo", offset: 0 }); + } else { + resetPending(state); + } + return { consume: true, actions }; + } + + if (ev.name === "tab") return PASS; + + // Pending text object: the object char after d/c/y + i/a (diw, caw, ...). + // Resolves to an inclusive range and applies the operator. Must run before + // the object char is interpreted as a motion (e.g. w). + if (state.pending.kind === "textobject") { + const { op, around } = state.pending; + const range = resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), key, around); + if (!range) { + resetPending(state); + return { consume: true, actions: [] }; + } + return applyOperatorRange(state, op, prompt.getPlainText(), range.start, range.end); + } + + // Everything below is consumed + const actions: Action[] = []; + + if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { + state.count = state.count * 10 + parseInt(key, 10); + return { consume: true, actions }; + } + + if (ev.name === "return") { + actions.push({ type: "cmd", cmd: "input.submit" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === ":") { + actions.push({ type: "cmd", cmd: "command.palette.show" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "/") { + actions.push({ type: "cmd", cmd: "session.timeline" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "[") { + actions.push({ type: "cmd", cmd: "session.half.page.up" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "]") { + actions.push({ type: "cmd", cmd: "session.half.page.down" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "{") { + actions.push({ type: "cmd", cmd: "session.message.previous" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "}") { + actions.push({ type: "cmd", cmd: "session.message.next" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "p") { + if (state.yankRegister) actions.push({ type: "yank", text: state.yankRegister }); + actions.push({ type: "cmd", cmd: "prompt.paste" }); + resetPending(state); + return finishUndoableChange(actions); + } + + if (key === "X") { + pushN(actions, "input.backspace", consumeCount(state)); + return finishUndoableChange(actions); + } + + if (key === "J") { + const n = consumeCount(state); + for (let i = 0; i < n; i++) { + actions.push({ type: "cmd", cmd: "input.line.end" }); + actions.push({ type: "cmd", cmd: "input.delete" }); + } + return finishUndoableChange(actions); + } + + // Operators: d, c, y + if (key === "d" || key === "c" || key === "y") { + if (state.pending.kind === "operator" && state.pending.op === key) { + const n = consumeCount(state); + if (key === "y") { + const cursorLine = prompt.getCursorLine(); + const lines: string[] = []; + for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i)); + const text = `${lines.join("\n")}\n`; + state.yankRegister = text; + actions.push({ type: "yank", text }); + actions.push({ type: "toast", message: `${n} line${n > 1 ? "s" : ""} yanked`, duration: 1000 }); + resetPending(state); + } else { + pushN(actions, "input.delete.line", n); + if (key === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + return { consume: true, actions }; + } + state.pending = { kind: "operator", op: key }; + return { consume: true, actions }; + } + + // Operator + i/a begins a text object (diw, caw, ...). Must precede the + // standalone i/a insert entries below — otherwise `di` falls through and + // enters insert instead of waiting for the object char (#57). + if (state.pending.kind === "operator" && (key === "i" || key === "a")) { + state.pending = { kind: "textobject", op: state.pending.op, around: key === "a" }; + return { consume: true, actions }; + } + + if (key === "D") { + actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); + resetPending(state); + return finishUndoableChange(actions); + } + + if (key === "C") { + actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); + enterInsert(state, actions); + return finishUndoableChange(actions); + } + + if (key === "f" || key === "F") { + const op = state.pending.kind === "operator" ? state.pending.op : undefined; + state.pending = { kind: "find", direction: key === "f" ? "forward" : "backward", op }; + return { consume: true, actions }; + } + + if (key === ";" || key === ",") { + if (!state.lastFind) { + resetPending(state); + return { consume: true, actions }; + } + const direction = key === ";" ? state.lastFind.direction : oppositeDirection(state.lastFind.direction); + return applyNormalFind(state, prompt, state.lastFind.char, direction, false); + } + + // Pending operator + e (end-of-word needs special handling) + if (state.pending.kind === "operator" && key === "e") { + const op = state.pending.op; + const n = consumeCount(state); + const offset = prompt.getCursorOffset(); + const target = endOfWord(prompt.getPlainText(), offset, n); + return applyOperatorRange(state, op, prompt.getPlainText(), offset, target); + } + + // Pending operator + motion + if (state.pending.kind === "operator" && key in MOTIONS) { + const op = state.pending.op; + const n = consumeCount(state); + + if (op === "y") { + const selectCmd = SELECT_MOTIONS[key]; + if (selectCmd) { + pushN(actions, selectCmd, n); + actions.push({ type: "yankSelection" }); + } + resetPending(state); + return { consume: true, actions }; + } + + if (key === "j") { + pushN(actions, "input.delete.line", n + 1); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + if (key === "k") { + pushN(actions, "input.move.up", n); + pushN(actions, "input.delete.line", n + 1); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + if (key === "G") { + consumeCount(state); + const offset = prompt.getCursorOffset(); + const text = prompt.getPlainText(); + return applyOperatorRange(state, op, text, offset, Math.max(0, text.length - 1)); + } + + const deleteCmd = DELETE_MOTION[key]; + if (deleteCmd) { + pushN(actions, deleteCmd, n); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + // unreachable: every MOTIONS key reaching here has a DELETE_MOTION entry (j/k/G handled above) + resetPending(state); + return { consume: true, actions }; + } + + // Standalone e (end-of-word) + if (key === "e") { + const n = consumeCount(state); + const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + // Standalone motions + if (key in MOTIONS) { + const n = consumeCount(state); + if ((key === "j" || key === "k") && isInputEmpty(prompt)) { + const cmd = key === "k" ? "prompt.history.previous" : "prompt.history.next"; + pushN(actions, cmd, n); + return { consume: true, actions }; + } + pushN(actions, MOTIONS[key], n); + return { consume: true, actions }; + } + + // g prefix — wait for second keypress + if (key === "g") { + state.pending = { kind: "goto" }; + return { consume: true, actions }; + } + + if (key === "x") { + pushN(actions, "input.delete", consumeCount(state)); + return finishUndoableChange(actions); + } + + if (key === "r") { + state.pending = { kind: "replace" }; + return { consume: true, actions }; + } + + if (key === "u") { + actions.push({ type: "undo" }); + resetPending(state); + return { consume: true, actions }; + } + + // Visual mode entry + if (key === "V") { + const range = currentLineRange(prompt.getPlainText(), prompt.getCursorOffset()); + state.mode = "visual"; + state.visualAnchor = prompt.getCursorOffset(); + state.oneShotNormal = false; + resetPending(state); + return { + consume: true, + actions: [ + { type: "selectRange", start: range.start, end: range.end }, + { type: "mode", mode: "visual" }, + ], + }; + } + + if (key === "v") { + state.mode = "visual"; + state.visualAnchor = prompt.getCursorOffset(); + state.oneShotNormal = false; + resetPending(state); + return { consume: true, actions: [{ type: "mode", mode: "visual" }] }; + } + + // Insert entries + if (key === "I") { + actions.push({ type: "cursorTo", offset: firstNonBlank(prompt.getPlainText(), prompt.getCursorOffset()) }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "i") { + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "a") { + actions.push({ type: "cmd", cmd: "input.move.right" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "A") { + actions.push({ type: "cmd", cmd: "input.line.end" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "o") { + actions.push({ type: "cmd", cmd: "input.line.end" }); + actions.push({ type: "cmd", cmd: "input.newline" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "O") { + actions.push({ type: "cmd", cmd: "input.line.home" }); + actions.push({ type: "cmd", cmd: "input.newline" }); + actions.push({ type: "cmd", cmd: "input.move.up" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + // Unbound key — already consumed + return { consume: true, actions }; +} + +function applyNormalFind( + state: VimState, + prompt: PromptAccess, + char: string, + direction: FindDirection, + remember: boolean, +): HandlerResult { + if (remember) state.lastFind = { char, direction }; + const op = state.pending.kind === "find" || state.pending.kind === "operator" ? state.pending.op : undefined; + const text = prompt.getPlainText(); + const offset = prompt.getCursorOffset(); + const target = findChar(text, offset, char, direction, consumeCount(state)); + resetPending(state); + if (target === null) return { consume: true, actions: [] }; + if (op) { + // Backward operator motions exclude the match, as in the original fork. + const start = direction === "forward" ? offset : target + 1; + const end = direction === "forward" ? target : offset; + return applyOperatorRange(state, op, text, start, end); + } + return { consume: true, actions: [{ type: "cursorTo", offset: target }] }; +} + +function finishUndoableChange(actions: Action[]): HandlerResult { + return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; +} + +// Apply a d/c/y operator to an inclusive [start, end] buffer range: yank +// copies the slice; delete/change remove it, with change entering insert. +// Shared by operator+e, operator+G, and the text-object dispatch. +function applyOperatorRange( + state: VimState, + op: Operator | undefined, + text: string, + start: number, + end: number, +): HandlerResult { + if (op === "y") { + const yanked = text.slice(start, end + 1); + state.yankRegister = yanked; + resetPending(state); + return { consume: true, actions: [{ type: "yank", text: yanked }] }; + } + const actions: Action[] = [{ type: "deleteRange", start, end }]; + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); +} + +function isInputEmpty(prompt: PromptAccess): boolean { + return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; +} diff --git a/src/vim/state.ts b/src/vim/state.ts new file mode 100644 index 0000000..93b3c1c --- /dev/null +++ b/src/vim/state.ts @@ -0,0 +1,77 @@ +import type { Action, HandlerResult, VimState } from "./types"; + +export function createVimState(): VimState { + return { + mode: "insert", + pending: { kind: "none" }, + count: 0, + yankRegister: "", + lastFind: null, + oneShotNormal: false, + disabled: false, + }; +} + +export function toggleVimMode(state: VimState): HandlerResult { + state.disabled = !state.disabled; + if (state.disabled) { + // Reset to clean insert mode so cursor style updates and no stale + // pending state carries over when re-enabled. + state.mode = "insert"; + state.pending = { kind: "none" }; + state.count = 0; + state.oneShotNormal = false; + return { + consume: true, + actions: [ + { type: "toast", message: "Vim mode disabled" }, + { type: "mode", mode: "insert" }, + ], + }; + } + return { consume: true, actions: [{ type: "toast", message: "Vim mode enabled" }] }; +} + +export function finishOneShotIfComplete(state: VimState, result: HandlerResult): void { + if (!state.oneShotNormal) return; + if (!result.consume) return; + if (state.pending.kind !== "none" || state.count > 0) return; + const alreadyEnteringInsert = result.actions.some((a) => a.type === "mode" && a.mode === "insert"); + if (alreadyEnteringInsert) { + state.oneShotNormal = false; + return; + } + state.oneShotNormal = false; + state.mode = "insert"; + result.actions.push({ type: "mode", mode: "insert" }); +} + +export function resetPending(state: VimState) { + state.pending = { kind: "none" }; + state.count = 0; +} + +export function consumeCount(state: VimState): number { + const n = state.count || 1; + state.count = 0; + return n; +} + +export function enterInsert(state: VimState, actions: Action[]) { + resetPending(state); + state.mode = "insert"; + state.oneShotNormal = false; + actions.push({ type: "mode", mode: "insert" }); +} + +export function enterNormal(state: VimState, actions: Action[]) { + state.mode = "normal"; + state.count = 0; + state.oneShotNormal = false; + actions.push({ type: "mode", mode: "normal" }); +} + +export function exitVisual(state: VimState, actions: Action[]) { + actions.push({ type: "clearSelection" }); + enterNormal(state, actions); +} diff --git a/src/vim/tables.ts b/src/vim/tables.ts new file mode 100644 index 0000000..9c01aa7 --- /dev/null +++ b/src/vim/tables.ts @@ -0,0 +1,35 @@ +export const MOTIONS: Record = { + h: "input.move.left", + l: "input.move.right", + j: "input.move.down", + k: "input.move.up", + w: "input.word.forward", + b: "input.word.backward", + "0": "input.line.home", + "^": "input.line.home", + $: "input.line.end", + G: "input.buffer.end", +}; + +export const SELECT_MOTIONS: Record = { + h: "input.select.left", + l: "input.select.right", + j: "input.select.down", + k: "input.select.up", + w: "input.select.word.forward", + b: "input.select.word.backward", + "0": "input.select.line.home", + "^": "input.select.line.home", + $: "input.select.line.end", + G: "input.select.buffer.end", +}; + +export const DELETE_MOTION: Record = { + w: "input.delete.word.forward", + b: "input.delete.word.backward", + $: "input.delete.to.line.end", + "0": "input.delete.to.line.start", + "^": "input.delete.to.line.start", + h: "input.backspace", + l: "input.delete", +}; diff --git a/src/vim/text.ts b/src/vim/text.ts new file mode 100644 index 0000000..ee471db --- /dev/null +++ b/src/vim/text.ts @@ -0,0 +1,242 @@ +import type { FindDirection, Range } from "./types"; + +export function findChar( + text: string, + offset: number, + char: string, + direction: FindDirection, + count = 1, +): number | null { + if (char.length !== 1 || text.length === 0) return null; + const safeOffset = Math.min(Math.max(offset, 0), text.length); + const lineStart = text.slice(0, safeOffset).lastIndexOf("\n") + 1; + const newline = text.indexOf("\n", safeOffset); + const lineEnd = newline === -1 ? text.length : newline; + let target = safeOffset; + for (let step = 0; step < count; step++) { + if (direction === "backward" && target <= lineStart) return null; + target = direction === "forward" ? text.indexOf(char, target + 1) : text.lastIndexOf(char, target - 1); + if (target < lineStart || target >= lineEnd) return null; + } + return target; +} + +export function firstNonBlank(text: string, offset: number): number { + const safeOffset = Math.min(Math.max(offset, 0), text.length); + const lineStart = text.slice(0, safeOffset).lastIndexOf("\n") + 1; + const newline = text.indexOf("\n", safeOffset); + const lineEnd = newline === -1 ? text.length : newline; + for (let target = lineStart; target < lineEnd; target++) { + if (text[target] !== " " && text[target] !== "\t") return target; + } + return lineStart; +} + +export function endOfWord(text: string, offset: number, count = 1): number { + const len = text.length; + if (len === 0) return 0; + let pos = offset; + for (let step = 0; step < count; step++) { + // If inside a word/punct run, advance one to start looking for next end + if (pos < len - 1 && charKind(text[pos]) !== "space") { + pos++; + } + // Skip whitespace + while (pos < len && isWhitespace(text[pos])) pos++; + if (pos >= len) return len - 1; + // Find end of current word class run + const kind = charKind(text[pos]); + while (pos + 1 < len && charKind(text[pos + 1]) === kind) pos++; + } + return Math.min(pos, len - 1); +} + +export function isWhitespace(ch: string): boolean { + return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"; +} + +export function charKind(ch: string): "word" | "punct" | "space" { + if (isWhitespace(ch)) return "space"; + if (/\w/.test(ch)) return "word"; + return "punct"; +} + +// A word/whitespace run never spans a line break. \r and \n both count so +// runs stay within a line on Windows (CRLF) as well as Unix. +function isLineBreak(ch: string): boolean { + return ch === "\n" || ch === "\r"; +} + +export function currentLineRange(text: string, offset: number): { start: number; end: number } { + if (text.length === 0) return { start: 0, end: 0 }; + const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); + const start = text.lastIndexOf("\n", safeOffset - 1) + 1; + const newline = text.indexOf("\n", safeOffset); + return { start, end: newline === -1 ? text.length - 1 : newline }; +} + +// Inclusive [start, end] offsets for the `iw`/`aw` text object under the +// cursor. A "word" is a run of one charKind (word/punct/space); runs never +// cross a newline. `around` extends past a word/punct run to its trailing +// whitespace (or leading, when there is none), and past a whitespace run to +// the following word. Returns null when there is nothing to select (empty +// text, or the cursor sits on a newline). +export function wordRange(text: string, offset: number, around: boolean): Range | null { + const len = text.length; + if (len === 0) return null; + const pos = Math.min(Math.max(offset, 0), len - 1); + if (isLineBreak(text[pos])) return null; + + const kind = charKind(text[pos]); + let start = pos; + while (start > 0 && !isLineBreak(text[start - 1]) && charKind(text[start - 1]) === kind) start--; + let end = pos; + while (end < len - 1 && !isLineBreak(text[end + 1]) && charKind(text[end + 1]) === kind) end++; + + if (!around) return { start, end }; + + if (kind === "space") { + if (end < len - 1 && !isLineBreak(text[end + 1])) { + const nextKind = charKind(text[end + 1]); + let te = end + 1; + while (te < len - 1 && !isLineBreak(text[te + 1]) && charKind(text[te + 1]) === nextKind) te++; + return { start, end: te }; + } + return { start, end }; + } + + let trailing = end; + while (trailing < len - 1 && !isLineBreak(text[trailing + 1]) && charKind(text[trailing + 1]) === "space") trailing++; + if (trailing > end) return { start, end: trailing }; + + let leading = start; + while (leading > 0 && !isLineBreak(text[leading - 1]) && charKind(text[leading - 1]) === "space") leading--; + return { start: leading, end }; +} + +// Inclusive range for a bracket pair text object (i(/a(, i{/a{, ...). Finds +// the innermost pair enclosing the cursor, depth-aware and across lines. A +// cursor sitting on either delimiter counts as inside that pair. `around` +// includes the delimiters; inner excludes them and returns null for an empty +// pair. Returns null when no pair encloses the cursor. `open` must differ +// from `close` (use quoteRange for symmetric delimiters). +export function bracketRange(text: string, offset: number, open: string, close: string, around: boolean): Range | null { + const len = text.length; + if (len === 0) return null; + const pos = Math.min(Math.max(offset, 0), len - 1); + + let openIdx: number; + let closeIdx: number; + if (text[pos] === open) { + openIdx = pos; + closeIdx = matchForward(text, pos, open, close); + } else if (text[pos] === close) { + closeIdx = pos; + openIdx = matchBackward(text, pos, open, close); + } else { + openIdx = enclosingOpen(text, pos, open, close); + closeIdx = openIdx === -1 ? -1 : matchForward(text, openIdx, open, close); + } + if (openIdx === -1 || closeIdx === -1) return null; + + if (around) return { start: openIdx, end: closeIdx }; + if (closeIdx - openIdx <= 1) return null; // empty pair — nothing inside + return { start: openIdx + 1, end: closeIdx - 1 }; +} + +// Scan left from an interior offset for the nearest open with no matching +// close between it and the cursor (the enclosing pair's opening). +function enclosingOpen(text: string, from: number, open: string, close: string): number { + let depth = 0; + for (let i = from; i >= 0; i--) { + if (text[i] === close) depth++; + else if (text[i] === open) { + if (depth === 0) return i; + depth--; + } + } + return -1; +} + +// Scan right from an opening delimiter for its matching close (depth-aware). +function matchForward(text: string, openIdx: number, open: string, close: string): number { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + if (text[i] === open) depth++; + else if (text[i] === close && --depth === 0) return i; + } + return -1; +} + +// Scan left from a closing delimiter for its matching open (depth-aware). +function matchBackward(text: string, closeIdx: number, open: string, close: string): number { + let depth = 0; + for (let i = closeIdx; i >= 0; i--) { + if (text[i] === close) depth++; + else if (text[i] === open && --depth === 0) return i; + } + return -1; +} + +// Inclusive range for a quote text object (i"/a", i'/a', ...). Quotes don't +// nest, so they pair left-to-right within the cursor's line (never across a +// newline). Returns the pair enclosing the cursor — a cursor on a quote counts +// as inside — with `around` including the quotes. Inner returns null for an +// empty pair; the function returns null when no pair encloses the cursor. +export function quoteRange(text: string, offset: number, quote: string, around: boolean): Range | null { + const len = text.length; + if (len === 0) return null; + const pos = Math.min(Math.max(offset, 0), len - 1); + const lineStart = text.lastIndexOf("\n", pos - 1) + 1; + const nextNewline = text.indexOf("\n", pos); + const lineEnd = nextNewline === -1 ? len : nextNewline; // exclusive + + const quotes: number[] = []; + for (let i = lineStart; i < lineEnd; i++) { + if (text[i] === quote) quotes.push(i); + } + + for (let p = 0; p + 1 < quotes.length; p += 2) { + const openIdx = quotes[p]; + const closeIdx = quotes[p + 1]; + if (pos < openIdx || pos > closeIdx) continue; + if (around) return { start: openIdx, end: closeIdx }; + if (closeIdx - openIdx <= 1) return null; // empty pair + return { start: openIdx + 1, end: closeIdx - 1 }; + } + return null; +} + +// `iq`/`aq`: the tightest quote pair (of " ' `) enclosing the cursor. +export function anyQuoteRange(text: string, offset: number, around: boolean): Range | null { + let winner: string | null = null; + let bestSpan = Infinity; + for (const quote of ['"', "'", "`"]) { + const span = quoteRange(text, offset, quote, true); + if (span && span.end - span.start < bestSpan) { + bestSpan = span.end - span.start; + winner = quote; + } + } + return winner === null ? null : quoteRange(text, offset, winner, around); +} + +// `ib`/`ab`: the tightest bracket pair (of () {} [], not angle) enclosing the +// cursor. Diverges from vim, where `ib` is parens only — see README. +export function anyBracketRange(text: string, offset: number, around: boolean): Range | null { + const pairs: Array<[string, string]> = [ + ["(", ")"], + ["{", "}"], + ["[", "]"], + ]; + let winner: [string, string] | null = null; + let bestSpan = Infinity; + for (const [open, close] of pairs) { + const span = bracketRange(text, offset, open, close, true); + if (span && span.end - span.start < bestSpan) { + bestSpan = span.end - span.start; + winner = [open, close]; + } + } + return winner === null ? null : bracketRange(text, offset, winner[0], winner[1], around); +} diff --git a/src/vim/textobject.ts b/src/vim/textobject.ts new file mode 100644 index 0000000..7c18f3f --- /dev/null +++ b/src/vim/textobject.ts @@ -0,0 +1,36 @@ +import { anyBracketRange, anyQuoteRange, bracketRange, quoteRange, wordRange } from "./text"; +import type { Range } from "./types"; + +// The single dispatch seam for text objects. Maps an object char (the key +// after `i`/`a`) to its inclusive [start, end] range under the cursor, or +// null when the char is not a text object or there is nothing to select. +// `q` is any quote (" ' `) and `b` is any bracket (() {} [], not angle); +// both pick the tightest pair enclosing the cursor. +export function resolveTextObject(text: string, offset: number, objectChar: string, around: boolean): Range | null { + switch (objectChar) { + case "w": + return wordRange(text, offset, around); + case '"': + case "'": + case "`": + return quoteRange(text, offset, objectChar, around); + case "q": + return anyQuoteRange(text, offset, around); + case "(": + case ")": + return bracketRange(text, offset, "(", ")", around); + case "{": + case "}": + return bracketRange(text, offset, "{", "}", around); + case "[": + case "]": + return bracketRange(text, offset, "[", "]", around); + case "<": + case ">": + return bracketRange(text, offset, "<", ">", around); + case "b": + return anyBracketRange(text, offset, around); + default: + return null; + } +} diff --git a/src/vim/types.ts b/src/vim/types.ts new file mode 100644 index 0000000..15b3281 --- /dev/null +++ b/src/vim/types.ts @@ -0,0 +1,60 @@ +export type Mode = "normal" | "insert" | "visual" | "(insert)"; +export type Operator = "d" | "c" | "y"; +export type FindDirection = "forward" | "backward"; + +export type Range = { start: number; end: number }; + +export type Pending = + | { kind: "none" } + | { kind: "operator"; op: Operator } + | { kind: "goto" } + | { kind: "replace" } + | { kind: "find"; direction: FindDirection; op?: Operator } + | { kind: "textobject"; op?: Operator; around: boolean }; + +export type Action = + | { type: "cmd"; cmd: string } + | { type: "mode"; mode: Mode } + | { type: "toast"; message: string; duration?: number } + | { type: "yank"; text: string } + | { type: "insertText"; text: string } + | { type: "yankSelection" } + | { type: "clearSelection" } + | { type: "deleteRange"; start: number; end: number } + | { type: "saveUndoSnapshot" } + | { type: "undo" } + | { type: "cursorTo"; offset: number } + | { type: "selectRange"; start: number; end: number }; + +export type HandlerResult = { + consume: boolean; + actions: Action[]; +}; + +export type VimState = { + mode: Mode; + pending: Pending; + count: number; + yankRegister: string; + lastFind: { char: string; direction: FindDirection } | null; + oneShotNormal: boolean; + disabled: boolean; + visualAnchor?: number; +}; + +export type KeyEvent = { + name: string; + shift?: boolean; + ctrl?: boolean; + meta?: boolean; + super?: boolean; + eventType?: string; +}; + +export type PromptAccess = { + getLine: (n: number) => string; + getLineCount: () => number; + getCursorLine: () => number; + getCursorOffset: () => number; + getPlainText: () => string; +}; diff --git a/src/vim/util.ts b/src/vim/util.ts new file mode 100644 index 0000000..5bc246e --- /dev/null +++ b/src/vim/util.ts @@ -0,0 +1,29 @@ +import type { Action, FindDirection, HandlerResult, KeyEvent } from "./types"; + +export function characterArgument(key: string): string { + if (key === "space") return " "; + if (key === "tab") return "\t"; + return key; +} + +export function oppositeDirection(direction: FindDirection): FindDirection { + return direction === "forward" ? "backward" : "forward"; +} + +export const PASS: HandlerResult = { consume: false, actions: [] }; + +export function translateKey(ev: KeyEvent): string { + let key = ev.name; + if (ev.shift && ev.name.length === 1) { + if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase(); + else if (ev.name === "4") key = "$"; + else if (ev.name === "6") key = "^"; + else if (ev.name === "[") key = "{"; + else if (ev.name === "]") key = "}"; + } + return key; +} + +export function pushN(actions: Action[], cmd: string, n: number) { + for (let i = 0; i < n; i++) actions.push({ type: "cmd", cmd }); +} diff --git a/src/vim/visual.ts b/src/vim/visual.ts new file mode 100644 index 0000000..fbbe456 --- /dev/null +++ b/src/vim/visual.ts @@ -0,0 +1,142 @@ +import { consumeCount, enterInsert, enterNormal, exitVisual, resetPending } from "./state"; +import { SELECT_MOTIONS } from "./tables"; +import { endOfWord, findChar } from "./text"; +import { resolveTextObject } from "./textobject"; +import type { Action, FindDirection, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { characterArgument, oppositeDirection, PASS, pushN } from "./util"; + +export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.meta || ev.super) return PASS; + if (ev.ctrl) return PASS; + + // Arrow keys are host navigation, not selection motions, so pass them + // through so vimcode never traps them (issue #63). + if (ev.name === "up" || ev.name === "down" || ev.name === "left" || ev.name === "right") return PASS; + + const actions: Action[] = []; + + if (state.pending.kind === "find") { + if (ev.name === "escape") resetPending(state); + else return applyVisualFind(state, prompt, characterArgument(key), state.pending.direction, true); + } + + // Pending g prefix in visual mode + if (state.pending.kind === "goto") { + state.pending = { kind: "none" }; + if (key === "g") { + actions.push({ type: "cmd", cmd: "input.select.buffer.home" }); + state.count = 0; + return { consume: true, actions }; + } + // Unknown g-combo or escape — fall through to normal visual handling + } + + // Pending text object: object char after i/a (viw, vaw, ...). Selects the + // resolved range. Must run before the object char is treated as a selection + // motion (e.g. w, b). + if (state.pending.kind === "textobject") { + const around = state.pending.around; + state.pending = { kind: "none" }; + const range = resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), key, around); + if (range) { + state.visualAnchor = range.start; + actions.push({ type: "selectRange", start: range.start, end: range.end }); + actions.push({ type: "cursorTo", offset: range.end }); + } + return { consume: true, actions }; + } + + // Count accumulation + if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { + state.count = state.count * 10 + parseInt(key, 10); + return { consume: true, actions }; + } + + // Exit visual mode + if (ev.name === "escape" || key === "v") { + exitVisual(state, actions); + return { consume: true, actions }; + } + + // Operators act on selection + if (key === "d" || key === "x") { + actions.push({ type: "cmd", cmd: "input.backspace" }); + enterNormal(state, actions); + return { consume: true, actions }; + } + + if (key === "c") { + actions.push({ type: "cmd", cmd: "input.backspace" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "y") { + actions.push({ type: "yankSelection" }); + enterNormal(state, actions); + return { consume: true, actions }; + } + + // e — extend selection to end of word (custom, not a host command) + if (key === "e") { + const n = consumeCount(state); + const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); + actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + if (key === "f" || key === "F") { + state.pending = { kind: "find", direction: key === "f" ? "forward" : "backward" }; + return { consume: true, actions }; + } + + if (key === ";" || key === ",") { + if (!state.lastFind) { + resetPending(state); + return { consume: true, actions }; + } + const direction = key === ";" ? state.lastFind.direction : oppositeDirection(state.lastFind.direction); + return applyVisualFind(state, prompt, state.lastFind.char, direction, false); + } + + // Motions extend selection + if (key in SELECT_MOTIONS) { + pushN(actions, SELECT_MOTIONS[key], consumeCount(state)); + return { consume: true, actions }; + } + + // i/a begin a text object (viw, vaw, ...); wait for the object char. + if (key === "i" || key === "a") { + state.pending = { kind: "textobject", around: key === "a" }; + return { consume: true, actions }; + } + + // g prefix — wait for second keypress + if (key === "g") { + state.pending = { kind: "goto" }; + return { consume: true, actions }; + } + + // Unbound key — consume to prevent typing + return { consume: true, actions }; +} + +function applyVisualFind( + state: VimState, + prompt: PromptAccess, + char: string, + direction: FindDirection, + remember: boolean, +): HandlerResult { + if (remember) state.lastFind = { char, direction }; + const target = findChar(prompt.getPlainText(), prompt.getCursorOffset(), char, direction, consumeCount(state)); + resetPending(state); + const actions: Action[] = []; + if (target !== null) { + // V2's cursor setter clears selection, so move before selecting the range. + actions.push({ type: "cursorTo", offset: target }); + actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + } + return { consume: true, actions }; +} diff --git a/test/child-session-navigation.test.ts b/test/child-session-navigation.test.ts new file mode 100644 index 0000000..9dfd0b7 --- /dev/null +++ b/test/child-session-navigation.test.ts @@ -0,0 +1,267 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import type { KeyInputContext, KeyInterceptOptions } from "@opentui/keymap"; +import { registerEnabledFields, registerLeader } from "@opentui/keymap/addons"; +import { createTestKeymap, TestKeymapEvent } from "@opentui/keymap/testing"; +import plugin from "../src/index"; + +const disposals: Array<() => void | Promise> = []; +const flushCommands = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// Let deferred prompt commands finish before disposing their keymap. +afterEach(async () => { + await flushCommands(); + for (const dispose of disposals.splice(0).reverse()) await dispose(); +}); + +// Exact remappings from #79. The host layer below models OpenCode v1's +// navigation commands; parsing, leader sequences, and dispatch are real OpenTUI. +const keybinds = { + leader: ",", + session_child_first: "j", + session_parent: "k", + session_child_cycle: "l", + session_child_cycle_reverse: "h", +}; + +async function setup(loadPlugin = true) { + const { keymap, host, cleanup, diagnostics } = createTestKeymap({ defaultKeys: true }); + disposals.push(cleanup); + registerEnabledFields(keymap); + registerLeader(keymap, { trigger: keybinds.leader }); + + const sessions: Record = { + root: {}, + "child-1": { parentID: "root" }, + "child-2": { parentID: "root" }, + }; + const route = { current: { name: "session", params: { sessionID: "root" } } }; + const promptTarget = host.rootTarget.append(host.createTarget("prompt")); + const editor = { + plainText: "hello world", + cursorOffset: 0, + visualCursor: { logicalRow: 0, logicalCol: 0 }, + cursorStyle: { style: "block", blinking: true }, + }; + const promptCommands: string[] = []; + const hostCommands: string[] = []; + let intercept: ((ctx: KeyInputContext) => void) | undefined; + + function navigate(sessionID: string) { + route.current.params.sessionID = sessionID; + host.focus(sessionID === "root" ? promptTarget : null); + } + navigate("root"); + + keymap.registerLayer({ + commands: [ + { + name: "session.child.first", + run: () => { + hostCommands.push("session.child.first"); + navigate("child-1"); + }, + }, + { + name: "session.parent", + enabled: () => !!sessions[route.current.params.sessionID]?.parentID, + run: () => { + hostCommands.push("session.parent"); + navigate("root"); + }, + }, + ...["next", "previous"].map((direction) => ({ + name: `session.child.${direction}`, + enabled: () => !!sessions[route.current.params.sessionID]?.parentID, + run: () => { + hostCommands.push(`session.child.${direction}`); + navigate(route.current.params.sessionID === "child-1" ? "child-2" : "child-1"); + }, + })), + ], + bindings: [ + { key: keybinds.session_child_first, cmd: "session.child.first" }, + { key: keybinds.session_parent, cmd: "session.parent" }, + { key: keybinds.session_child_cycle, cmd: "session.child.next" }, + { key: keybinds.session_child_cycle_reverse, cmd: "session.child.previous" }, + ], + }); + + const api = { + renderer: { + get currentFocusedEditor() { + return route.current.params.sessionID === "root" ? editor : undefined; + }, + }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: ( + name: "key", + handler: (ctx: KeyInputContext) => void, + options?: KeyInterceptOptions, + ) => { + intercept = handler; + const unregister = keymap.intercept(name, handler, options); + disposals.push(unregister); + return unregister; + }, + registerLayer: (layer: Parameters[0]) => keymap.registerLayer(layer), + dispatchCommand: (command: string) => { + promptCommands.push(command); + return keymap.dispatchCommand(command); + }, + }, + tuiConfig: { + keybinds: { + get: (name: string) => { + const key = keybinds[name as keyof typeof keybinds]; + return key ? [{ key }] : []; + }, + }, + }, + route, + state: { session: { get: (id: string) => sessions[id], question: () => [], permission: () => [] } }, + lifecycle: { + onDispose: (dispose: () => void | Promise) => { + disposals.push(dispose); + }, + }, + kv: {}, + }; + + if (loadPlugin) { + await plugin.tui(api as unknown as TuiPluginApi, { + startMode: "normal", + modeIndicator: "none", + updateCheck: false, + }); + } + + function pressIntercept(name: string) { + if (!intercept) throw new Error("Plugin did not register its key intercept"); + let consumed = false; + intercept({ + event: new TestKeymapEvent(name), + consume: () => { + consumed = true; + }, + setData: (key, value) => keymap.setData(key, value), + getData: (key) => keymap.getData(key), + }); + return consumed; + } + + return { api, diagnostics, host, hostCommands, navigate, pressIntercept, promptCommands, route }; +} + +describe("#79 — child-session key intercept", () => { + for (const key of ["h", "k", "l"]) { + it(`${key} passes through in a child session and normal mode survives returning to the parent`, async () => { + const { api, navigate, pressIntercept, promptCommands } = await setup(); + expect(pressIntercept(",")).toBe(false); + expect(pressIntercept("j")).toBe(false); + navigate("child-1"); + expect(api.renderer.currentFocusedEditor).toBeUndefined(); + + expect(pressIntercept(key)).toBe(false); + await flushCommands(); + expect(promptCommands).toEqual([]); + + navigate("root"); + expect(pressIntercept("h")).toBe(true); + await flushCommands(); + expect(promptCommands).toEqual(["input.move.left"]); + }); + } + + it("suspends visual mode in child sessions without changing the mode on return", async () => { + const { navigate, pressIntercept, promptCommands } = await setup(); + expect(pressIntercept("v")).toBe(true); + navigate("child-1"); + for (const key of ["escape", "i", "h", "k", "l"]) expect(pressIntercept(key)).toBe(false); + await flushCommands(); + expect(promptCommands).toEqual([]); + + navigate("root"); + expect(pressIntercept("j")).toBe(true); + await flushCommands(); + expect(promptCommands).toEqual(["input.select.down"]); + }); + + it("still consumes prompt motions in the parent session", async () => { + const { pressIntercept, promptCommands } = await setup(); + expect(pressIntercept("h")).toBe(true); + await flushCommands(); + expect(promptCommands).toEqual(["input.move.left"]); + }); + + it("suspends insert handling in child sessions without changing the mode on return", async () => { + const { navigate, pressIntercept, promptCommands } = await setup(); + expect(pressIntercept("i")).toBe(true); + navigate("child-1"); + for (const key of ["escape", "return", "tab", ",", "h", "k", "l"]) expect(pressIntercept(key)).toBe(false); + await flushCommands(); + expect(promptCommands).toEqual([]); + + navigate("root"); + expect(pressIntercept("z")).toBe(false); + }); +}); + +describe("#79 — real OpenTUI keymap dispatch", () => { + it("the issue's bindings navigate children and return to the parent without vimcode", async () => { + const { diagnostics, host, hostCommands, route } = await setup(false); + host.press(","); + host.press("j"); + expect(route.current.params.sessionID).toBe("child-1"); + host.press("l"); + expect(route.current.params.sessionID).toBe("child-2"); + host.press("h"); + expect(route.current.params.sessionID).toBe("child-1"); + host.press("k"); + expect(route.current.params.sessionID).toBe("root"); + expect(hostCommands).toEqual([ + "session.child.first", + "session.child.next", + "session.child.previous", + "session.parent", + ]); + expect(diagnostics.errors).toEqual([]); + }); + + it(",j i k reproduces the existing workaround for returning to the parent", async () => { + const { diagnostics, host, hostCommands, promptCommands, route } = await setup(); + host.press(","); + host.press("j"); + expect(route.current.params.sessionID).toBe("child-1"); + host.press("i"); + host.press("k"); + expect(route.current.params.sessionID).toBe("root"); + expect(hostCommands).toEqual(["session.child.first", "session.parent"]); + await flushCommands(); + expect(promptCommands).toEqual([]); + expect(diagnostics.errors).toEqual([]); + }); + + for (const [key, command, target] of [ + ["h", "session.child.previous", "child-2"], + ["k", "session.parent", "root"], + ["l", "session.child.next", "child-2"], + ]) { + it(`,j then ${key} runs ${command} without switching to insert mode`, async () => { + const { diagnostics, host, hostCommands, promptCommands, route } = await setup(); + host.press(","); + host.press("j"); + expect(route.current.params.sessionID).toBe("child-1"); + expect(hostCommands).toEqual(["session.child.first"]); + expect(diagnostics.errors).toEqual([]); + + host.press(key); + expect(route.current.params.sessionID).toBe(target); + expect(hostCommands).toEqual(["session.child.first", command]); + await flushCommands(); + expect(promptCommands).toEqual([]); + expect(diagnostics.errors).toEqual([]); + }); + } +}); 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..2d37ce7 --- /dev/null +++ b/test/compat/cases.py @@ -0,0 +1,361 @@ +"""Exact-buffer scenarios for the installed OpenCode TUI package.""" +from driver import atomic_json, capture, command, compare_state, 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}) + + 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" + 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_fork_bindings(ctx): + seed(ctx, " alpha beta", 10) + keys(ctx, "I!") + record(ctx, "fork-I-first-nonblank", {"text": " !alpha beta", "offset": 3}) + seed(ctx, "xaxbxcx") + keys(ctx, "fx") + record(ctx, "fork-find-forward", {"offset": 2}) + send(ctx, ";") + record(ctx, "fork-find-repeat", {"offset": 4}) + send(ctx, ",") + record(ctx, "fork-find-reverse", {"offset": 2}) + keys(ctx, "2;") + record(ctx, "fork-find-counted-repeat", {"offset": 6}) + keys(ctx, "Fx") + record(ctx, "fork-find-backward", {"offset": 4}) + keys(ctx, "d,") + record(ctx, "fork-find-operator-repeat", {"text": "xaxb", "offset": 4}) + send(ctx, "u") + record(ctx, "fork-find-undo", {"text": "xaxbxcx", "offset": 4}) + seed(ctx, "xaxbxcx", 6) + keys(ctx, "dFx") + record(ctx, "fork-find-backward-exclusive", {"text": "xaxbx", "offset": 5}) + send(ctx, "u") + record(ctx, "fork-find-backward-undo", {"text": "xaxbxcx", "offset": 6}) + seed(ctx, "xaxbxcx") + keys(ctx, "cfxZ") + record(ctx, "fork-find-change", {"text": "Zbxcx", "offset": 1}) + seed(ctx, "xaxbxcx") + keys(ctx, "vfx") + record(ctx, "fork-find-visual-selection", {"offset": 2, "selected": "xax", "selection": {"start": 0, "end": 3}}) + send(ctx, "x") + record(ctx, "fork-find-visual-delete", {"text": "bxcx", "offset": 0}) + seed(ctx, "xaxbxcx", 6) + keys(ctx, "vFx") + record(ctx, "fork-find-visual-backward-selection", {"offset": 4, "selected": "xcx", "selection": {"start": 4, "end": 7}}) + send(ctx, "x") + record(ctx, "fork-find-visual-backward-delete", {"text": "xaxb", "offset": 4}) + seed(ctx, "a b c") + keys(ctx, "f ") + record(ctx, "fork-find-space-leader-target", {"text": "a b c", "offset": 1}) + send(ctx, ";") + record(ctx, "fork-find-space-repeat", {"offset": 3}) + seed(ctx, "abc\ndef") + keys(ctx, "fd") + record(ctx, "fork-find-line-boundary", {"text": "abc\ndef", "offset": 0}) + seed(ctx, "xaxbxcx") + send(ctx, "i") + send(ctx, "C-o", False) + keys(ctx, "fx!") + record(ctx, "fork-find-one-shot", {"text": "xa!xbxcx", "offset": 3, + "cursorStyle": {"style": "line", "blinking": True}}) + + +def run_v2_find_contexts(ctx): + seed(ctx, "xaxbxcx") + keys(ctx, "fx") + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value.get("editorStatus") == "ANSWER", "find-isolation answer") + send(ctx, "xaxbxcx") + send(ctx, "Escape", False) + send(ctx, ";") + record(ctx, "fork-answer-no-inherited-find", {"text": "xaxbxcx", "offset": 6}) + keys(ctx, "Fx;") + record(ctx, "fork-answer-own-backward-find", {"offset": 2}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "xaxbxcx"}}) + send(ctx, ";") + record(ctx, "fork-main-find-restored", {"text": "xaxbxcx", "offset": 4}) + + +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" 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": + send(ctx, ch) + record(ctx, "root-form-space", {"text": "hello world", "mode": "form", "editorStatus": "ANSWER", + "cursorStyle": {"style": "line", "blinking": True}}) + send(ctx, "Escape", False) + record(ctx, "root-form-escape-normal", {"text": "hello world", "offset": 10, "mode": "form", + "cursorStyle": {"style": "block", "blinking": True}}) + keys(ctx, "hx") + record(ctx, "root-form-normal-delete", {"text": "hello word", "offset": 9, "mode": "form"}) + keys(ctx, "il") + record(ctx, "root-form-insert-correction", {"text": "hello world", "offset": 10, + "cursorStyle": {"style": "line", "blinking": True}}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "hello world"}}) + for kind, burst in (("key", ":x"), ("paste", ":\x1b[200~x\x1b[201~")): + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "palette-order form") + send(ctx, "hello world") + send(ctx, "Escape", False) + record(ctx, f"root-form-{kind}-before-palette", {"text": "hello world", "offset": 10, "mode": "form"}) + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", burst) + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "modal", "palette owns burst input") + record(ctx, f"root-form-{kind}-palette-query", {"text": "x", "mode": "modal", "editorStatus": "FILTER"}) + send(ctx, "Escape", False) + record(ctx, f"root-form-{kind}-after-palette", {"text": "hello world", "offset": 10, "mode": "form", + "editorStatus": "ANSWER", "cursorStyle": {"style": "block", "blinking": True}}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "hello world"}}) + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "burst-edit form") + send(ctx, "burst draft") + send(ctx, "Escape", False) + record(ctx, "root-form-before-burst", {"text": "burst draft", "offset": 10, "mode": "form"}) + # One terminal write: no timer turn may separate the edit from Enter. + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "x\r") + wait_for(lambda: snapshot(ctx), lambda value: value.get("editorStatus") != "ANSWER", "burst answer committed") + if command(ctx, "formState").get("state", {}).get("status") == "pending": + send(ctx, "Enter", False) + expected = {"state": {"status": "answered", "answer": {"answer": "burst draf"}}} + actual = completed_command(ctx, "formState", lambda result: result.get("state") == expected["state"]) + ctx["report"]["checks"].append(compare_state("root-form-burst-submit", actual, expected)) + save(ctx) + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value.get("editorStatus") == "ANSWER", "paste-order answer") + send(ctx, "hello world") + send(ctx, "Escape", False) + record(ctx, "root-form-before-append-paste", {"text": "hello world", "offset": 10, "mode": "form"}) + # Native paste bypasses key dispatch; it must still follow the append motion. + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "a\x1b[200~X\x1b[201~") + wait_for(lambda: snapshot(ctx), lambda value: "X" in (value.get("text") or ""), "answer paste delivered") + record(ctx, "root-form-append-paste-burst", {"text": "hello worldX", "offset": 12, "mode": "form"}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "hello worldX"}}) + 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", "editorStatus": "ANSWER", + "cursorStyle": {"style": "line", "blinking": True}}) + send(ctx, "Escape", False) + record(ctx, "child-form-escape-normal", {"text": "child words", "offset": 10, "mode": "form", + "cursorStyle": {"style": "block", "blinking": True}}) + send(ctx, "x") + record(ctx, "child-form-normal-delete", {"text": "child word", "offset": 10}) + keys(ctx, "i!") + record(ctx, "child-form-insert-correction", {"text": "child word!", "offset": 11, + "cursorStyle": {"style": "line", "blinking": True}}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "child word!"}}) + 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) + completed_command(ctx, "permissions", lambda result: result == []) + + +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", + "cursorStyle": {"style": "line", "blinking": True}}) + 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..b83e8af --- /dev/null +++ b/test/compat/driver.py @@ -0,0 +1,293 @@ +"""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"], "-f", "/dev/null", *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): + # tmux parses a bare semicolon as a command separator even without a shell. + if literal and key == ";": + key = "\\;" + 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, 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) + 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..a6ee904 --- /dev/null +++ b/test/compat/fixture/tui.ts @@ -0,0 +1,265 @@ +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; + +type Editor = { + id?: string; + traits?: { status?: 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; getTextRange?: (start: number, end: number) => string }; + 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, + 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, + cursorStyle: editor?.cursorStyle ?? null, + keypressListeners: renderer.keyInput?.listeners("keypress").length ?? null, + editorId: editor?.id ?? null, + editorStatus: editor?.traits?.status ?? 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..693cc9b --- /dev/null +++ b/test/compat/run.py @@ -0,0 +1,107 @@ +#!/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_fork_bindings, run_v2_find_contexts, 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", "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}") + 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) + run_fork_bindings(ctx) + if args.host == "v2": + run_v2_prompts(ctx) + run_v2_find_contexts(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_driver.py b/test/compat/test_driver.py new file mode 100644 index 0000000..faae7b7 --- /dev/null +++ b/test/compat/test_driver.py @@ -0,0 +1,120 @@ +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 +import uuid + +from cases import completed_command +from driver import send, 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(): + 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_literal_semicolon_is_not_a_tmux_command_separator(): + with patch("driver.tmux") as dispatch, patch("driver.time.sleep"), patch("driver.fresh_snapshot"): + send({"pane": "owned:0.0"}, ";") + dispatch.assert_called_once_with({"pane": "owned:0.0"}, "send-keys", "-t", "owned:0.0", "-l", "\\;") + + +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/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/e2e/driver.integration.ts b/test/e2e/driver.integration.ts new file mode 100644 index 0000000..53d7e08 --- /dev/null +++ b/test/e2e/driver.integration.ts @@ -0,0 +1,313 @@ +import { expect, it } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bounded, closeDriver, ownDriver } from "./driver"; +import { + type HostRun, + prepareRun, + pressKey, + scenario, + startScenario, + stopScenario, + typeText, + writeInput, +} from "./host"; + +if (!["darwin", "linux"].includes(process.platform)) + throw new Error("Harness integration process checks require macOS or Linux"); + +function fixture(mode: "ready-stall" | "shutdown-stall" | "launch-stall" | "abort" | "send-stall" | "startup-exit") { + const directory = mkdtempSync(join(realpathSync(tmpdir()), "vimcode-driver-probe-")); + const binaryPath = join(directory, "driver"); + writeFileSync( + binaryPath, + `#!${process.execPath} +import { spawn } from "node:child_process"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +process.on("SIGTERM", () => {}); +setInterval(() => {}, 1000); +let launchedHost; +if (${JSON.stringify(mode)} !== "ready-stall") { + console.log(JSON.stringify({ type: "hello", protocolVersion: 2 })); + createInterface({ input: process.stdin }).on("line", line => { + const request = JSON.parse(line); + appendFileSync(${JSON.stringify(join(directory, "requests.txt"))}, request.method + "\\n"); + const result = value => console.log(JSON.stringify({ type: "result", id: request.id, result: value })); + if (${JSON.stringify(mode)} === "startup-exit" && request.method !== "launch") { + if (request.method === "capture") result({ reason: "exited", shot: { + text: "Standalone server exited before reporting readiness", frame: { cursor: null } + }}); + else if (request.method === "status") result({ state: "exited", exit: { code: 1, signal: null, success: false } }); + else if (request.method === "recording") result({ bytes: [] }); + else if (request.method === "stop") { + if (launchedHost?.exitCode === null) { + launchedHost.once("exit", () => result(null)); + launchedHost.kill("SIGKILL"); + } else result(null); + } else if (request.method === "shutdown") { + result(null); + setImmediate(() => process.exit(0)); + } + } + if (${JSON.stringify(mode)} === "send-stall" && request.method !== "launch") { + if (request.method === "capture") result({ reason: "idle", shot: { + text: " ┃\\n ┃ Ask\\n ┃\\n ┃ Build · No provider selected Connect a provider\\n ╹▀▀▀▀▀", + frame: { cursor: null } + }}); + else if (request.method === "recording") result({ bytes: [] }); + else if (request.method === "stop") result(null); + // SEND and shutdown deliberately never respond; the real scenario must unwind/reap. + } + if (request.method === "launch") { + const host = spawn(${JSON.stringify(process.execPath)}, ["-e", "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"], { detached: true, stdio: "ignore" }); + launchedHost = host; + writeFileSync(${JSON.stringify(join(directory, "host.pid"))}, String(host.pid)); + if (${JSON.stringify(mode)} === "startup-exit") { + mkdirSync(${JSON.stringify(join(directory, "opencode/log"))}, { recursive: true }); + appendFileSync(${JSON.stringify(join(directory, "opencode/log/opencode.log"))}, "level=ERROR message=synthetic-startup-cause\\n"); + } + if (${JSON.stringify(mode)} === "abort") console.log("invalid-json"); + else if (${JSON.stringify(mode)} !== "launch-stall") console.log(JSON.stringify({ type: "result", id: request.id, result: null })); + } + }); +} +`, + { mode: 0o755 }, + ); + return { directory, binaryPath }; +} + +function present(pid: number) { + return execFileSync("/bin/ps", ["-axo", "pid="], { encoding: "utf8" }).split(/\s+/).includes(String(pid)); +} + +for (const mode of ["ready-stall", "shutdown-stall", "launch-stall", "abort"] as const) { + it(`owned driver reaps ${mode} and its detached host`, async () => { + const probe = fixture(mode); + const env = Object.fromEntries(Object.keys(process.env).map((key) => [key, undefined])); + Object.assign(env, { HOME: probe.directory, PATH: "/usr/bin:/bin" }); + const owner = await ownDriver({ binaryPath: probe.binaryPath, cwd: probe.directory, env }); + const pid = owner.child.pid; + if (!pid) throw new Error("missing owned driver PID"); + try { + if (mode === "ready-stall") { + await expect(bounded(owner.ready, "probe readiness", 100)).rejects.toThrow("probe readiness exceeded"); + } else { + await bounded(owner.ready, "probe readiness", 2_000); + const launch = owner.terminal.launch({ command: ["unused"] }); + if (mode === "abort") { + await expect(bounded(launch, "probe launch", 2_000)).rejects.toThrow("invalid termctrl driver response"); + } else if (mode === "launch-stall") { + await expect(bounded(launch, "probe launch", 200)).rejects.toThrow("probe launch exceeded"); + } else { + await bounded(launch, "probe launch", 2_000); + } + } + await expect(closeDriver(owner, 100)).rejects.toThrow(/driver (shutdown|reap) exceeded/); + expect(owner.reaped).toBe(true); + expect(present(pid)).toBe(false); + if (mode !== "ready-stall") { + const hostPid = Number(readFileSync(join(probe.directory, "host.pid"), "utf8")); + expect(owner.hostGroups.has(hostPid)).toBe(true); + expect(present(hostPid)).toBe(false); + } + } finally { + if (!owner.reaped) await closeDriver(owner, 1_000).catch(() => {}); + rmSync(probe.directory, { recursive: true, force: true }); + } + }); +} + +for (const send of ["type", "press", "write"] as const) { + it(`scenario captures stalled keyboard ${send} failure and finally reaps its driver/host`, async () => { + const probe = fixture("send-stall"); + const run: HostRun = { + output: probe.directory, + env: { HOME: probe.directory, PATH: "/usr/bin:/bin" }, + receipt: { + host: "v1", + binary: "unused", + version: "unused", + platform: process.platform, + terminalControl: "1.2.1", + driverBinary: probe.binaryPath, + negativeControl: false, + checks: [], + cleanupErrors: [], + }, + }; + let owner: HostRun["driver"]; + try { + await expect( + scenario(run, `send-${send}`, async () => { + owner = run.driver; + if (send === "type") await typeText(run, "synthetic", 100); + else if (send === "press") await pressKey(run, "Escape", 100); + else await writeInput(run, "synthetic", 100); + throw new Error("stalled send must not complete"); + }), + ).rejects.toThrow("driver shutdown exceeded"); + if (!owner?.child.pid) throw new Error("scenario did not retain driver ownership"); + const hostPid = Number(readFileSync(join(probe.directory, "host.pid"), "utf8")); + const receipt = JSON.parse(readFileSync(join(probe.directory, "receipt.json"), "utf8")) as { + checks: Array<{ name: string; status: string; error: string }>; + cleanupErrors: string[]; + }; + expect(receipt.checks).toEqual([ + { + name: `send-${send}`, + status: "fail", + error: `keyboard ${send === "write" ? "raw write" : send} exceeded 100ms`, + }, + ]); + expect(receipt.cleanupErrors).toEqual(["Error: driver shutdown exceeded 5000ms"]); + expect(readFileSync(join(probe.directory, `send-${send}/failure.txt`), "utf8")).toContain("No provider selected"); + expect(readFileSync(join(probe.directory, `send-${send}/failure.termctrl`)).length).toBe(0); + const requests = readFileSync(join(probe.directory, "requests.txt"), "utf8").trim().split("\n"); + expect(requests).toContain("send"); + expect(requests.slice(-3)).toEqual(["recording", "stop", "shutdown"]); + expect(owner.reaped).toBe(true); + expect(owner.hostGroups.has(hostPid)).toBe(true); + expect(present(owner.child.pid)).toBe(false); + expect(present(hostPid)).toBe(false); + expect(run.driver).toBeUndefined(); + expect(run.session).toBeUndefined(); + } finally { + if (run.driver && !run.driver.reaped) await closeDriver(run.driver, 1_000).catch(() => {}); + rmSync(probe.directory, { recursive: true, force: true }); + } + }, 20_000); +} + +it("retains native startup exit status and current-run error logs without retrying or entering the test", async () => { + const probe = fixture("startup-exit"); + mkdirSync(join(probe.directory, "opencode/log"), { recursive: true }); + writeFileSync(join(probe.directory, "opencode/log/opencode.log"), "level=ERROR message=previous-scenario\n"); + const run: HostRun = { + output: probe.directory, + env: { HOME: probe.directory, XDG_DATA_HOME: probe.directory, PATH: "/usr/bin:/bin" }, + receipt: { + host: "v2", + binary: "unused", + version: "2.0.15", + platform: process.platform, + terminalControl: "1.2.1", + driverBinary: probe.binaryPath, + negativeControl: false, + checks: [], + cleanupErrors: [], + }, + }; + let entered = false; + try { + await expect( + scenario(run, "startup-exit", async () => { + entered = true; + }), + ).rejects.toThrow("OpenCode capture ended with exited"); + expect(entered).toBe(false); + expect(run.receipt.cleanupErrors).toEqual([]); + expect(run.receipt.checks[0]).toEqual({ + name: "startup-exit", + status: "fail", + error: "OpenCode capture ended with exited", + }); + expect(run.receipt.providersDisabled).toBeUndefined(); + expect(JSON.parse(readFileSync(join(probe.directory, "startup-exit/failure-status.json"), "utf8"))).toEqual({ + state: "exited", + exit: { code: 1, signal: null, success: false }, + }); + expect(readFileSync(join(probe.directory, "startup-exit/host-error.log"), "utf8")).toBe( + "level=ERROR message=synthetic-startup-cause\n", + ); + expect( + readFileSync(join(probe.directory, "requests.txt"), "utf8") + .split("\n") + .filter((method) => method === "launch"), + ).toHaveLength(1); + expect(present(Number(readFileSync(join(probe.directory, "host.pid"), "utf8")))).toBe(false); + expect(run.driver).toBeUndefined(); + } finally { + if (run.driver && !run.driver.reaped) await closeDriver(run.driver, 1_000).catch(() => {}); + rmSync(probe.directory, { recursive: true, force: true }); + } +}, 20_000); + +it("retains unresolved ownership across cleanup retries and blocks later launches", async () => { + const probe = fixture("shutdown-stall"); + const env = Object.fromEntries(Object.keys(process.env).map((key) => [key, undefined])); + Object.assign(env, { HOME: probe.directory, PATH: "/usr/bin:/bin" }); + const owner = await ownDriver({ binaryPath: probe.binaryPath, cwd: probe.directory, env }); + const run: HostRun = { + output: probe.directory, + env: {}, + driver: owner, + terminal: owner.terminal, + receipt: { + host: "v1", + binary: "unused", + version: "unused", + platform: process.platform, + terminalControl: "1.2.1", + negativeControl: false, + checks: [], + cleanupErrors: [], + }, + }; + try { + await bounded(owner.ready, "probe readiness", 2_000); + await bounded(owner.terminal.launch({ command: ["unused"] }), "probe launch", 2_000); + owner.discoveryError = new Error("injected unresolved ownership"); + await expect(closeDriver(owner, 100)).rejects.toThrow("injected unresolved ownership"); + await owner.closed; + expect(owner.reaped).toBe(false); + await expect(stopScenario(run)).rejects.toThrow("injected unresolved ownership"); + expect(owner.reaped).toBe(false); + expect(run.driver).toBe(owner); + await expect(startScenario(run, "must-not-launch")).rejects.toThrow("refusing another launch"); + expect(present(Number(readFileSync(join(probe.directory, "host.pid"), "utf8")))).toBe(false); + } finally { + // The injected discovery failure is the only unresolved condition in this fixture. + owner.discoveryError = undefined; + await closeDriver(owner, 1_000).catch(() => {}); + rmSync(probe.directory, { recursive: true, force: true }); + } +}); + +it("rejects inherited TERMCTRL_BINARY before binary execution and records the reason", () => { + const output = mkdtempSync(join(realpathSync(tmpdir()), "vimcode-override-probe-")); + const keys = [ + "TERMCTRL_BINARY", + "VIMCODE_E2E_HOST", + "VIMCODE_E2E_BIN", + "VIMCODE_E2E_VERSION", + "VIMCODE_E2E_OUTPUT", + ] as const; + const before = keys.map((key) => process.env[key]); + Object.assign(process.env, { + TERMCTRL_BINARY: "/not-a-pinned-driver", + VIMCODE_E2E_HOST: "v1", + VIMCODE_E2E_BIN: "/must-not-execute", + VIMCODE_E2E_VERSION: "1.18.34", + VIMCODE_E2E_OUTPUT: output, + }); + try { + expect(() => prepareRun()).toThrow("TERMCTRL_BINARY override is unsupported"); + const receipt = JSON.parse(readFileSync(join(output, "receipt.json"), "utf8")) as { + checks: Array<{ status: string; error: string }>; + driverBinary?: string; + }; + expect(receipt.checks[0].status).toBe("fail"); + expect(receipt.checks[0].error).toContain("unset it"); + expect(receipt.driverBinary).toBeUndefined(); + } finally { + keys.forEach((key, index) => { + if (before[index] === undefined) delete process.env[key]; + else process.env[key] = before[index]; + }); + rmSync(output, { recursive: true, force: true }); + } +}); diff --git a/test/e2e/driver.ts b/test/e2e/driver.ts new file mode 100644 index 0000000..f329cab --- /dev/null +++ b/test/e2e/driver.ts @@ -0,0 +1,159 @@ +import { ChildProcess, execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { type DriverOptions, TerminalControl } from "@kitlangton/terminal-control"; + +export async function bounded(operation: Promise, label: string, timeoutMs = 5_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +type PrivateDriver = { child: ChildProcess; ready: Promise; abort: (error: Error) => void }; +export type OwnedDriver = { + terminal: TerminalControl; + ready: Promise; + child: ChildProcess; + closed: Promise; + hostGroups: Set; + frozen: boolean; + reaped: boolean; + discoveryError?: unknown; +}; + +type ProcessRow = { pid: number; parent: number; group: number; state: string }; +function processes(): ProcessRow[] { + return execFileSync("/bin/ps", ["-axo", "pid=,ppid=,pgid=,stat="], { encoding: "utf8", timeout: 1_000 }) + .trim() + .split("\n") + .map((line) => { + const [pid, parent, group, state] = line.trim().split(/\s+/); + return { pid: Number(pid), parent: Number(parent), group: Number(group), state }; + }); +} + +function signal(identity: number, value: NodeJS.Signals) { + try { + process.kill(identity, value); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +// portable-pty gives each launched host a new session/group (PGID = host PID). +// Freeze the driver before discovery: even a timed-out launch cannot spawn after +// the ownership snapshot. Capture before SDK abort too, which otherwise kills it. +function retainHosts(owner: OwnedDriver) { + const pid = owner.child.pid; + if (!pid || owner.child.exitCode !== null || owner.child.signalCode !== null) return; + signal(pid, "SIGSTOP"); + owner.frozen = true; + const deadline = Date.now() + 1_000; + let rows = processes(); + while (rows.some((row) => row.pid === pid && !row.state.startsWith("T"))) { + if (Date.now() >= deadline) throw new Error(`Owned driver PID ${pid} did not freeze`); + rows = processes(); + } + for (const row of rows) { + if (row.parent !== pid) continue; + if (row.group !== row.pid) throw new Error(`Unexpected owned PTY process group for PID ${row.pid}`); + owner.hostGroups.add(row.group); + } +} + +// SDK 1.2.1 has no public pre-ready ownership or forced-close API. Keep this +// guarded private boundary local to the optional spike; never patch the SDK. +export async function ownDriver(options: DriverOptions): Promise { + if (!["darwin", "linux"].includes(process.platform)) throw new Error("Owned PTY cleanup requires macOS or Linux"); + const entry = fileURLToPath(import.meta.resolve("@kitlangton/terminal-control")); + const manifest = JSON.parse(readFileSync(join(dirname(entry), "../package.json"), "utf8")) as { version: string }; + const constructorSource = TerminalControl.toString(); + const prototype = TerminalControl.prototype as unknown as PrivateDriver; + if ( + manifest.version !== "1.2.1" || + createHash("sha256").update(readFileSync(entry)).digest("hex") !== + "b4476b7d422818b1bd56397a2571db9ffdfe5122799172c572f4dc788edebee1" || + !constructorSource.includes("this.child = spawn(") || + !constructorSource.includes("this.ready = new Promise(") || + typeof prototype.abort !== "function" || + !prototype.abort.toString().includes("this.child.kill()") + ) { + throw new Error("Terminal Control private lifecycle shape changed; expected pinned SDK 1.2.1"); + } + const Constructor = TerminalControl as unknown as new (options: DriverOptions) => TerminalControl; + const terminal = new Constructor(options); + const internals = terminal as unknown as PrivateDriver; + const child = internals.child; + if (!(child instanceof ChildProcess)) throw new Error("Terminal Control private child shape changed"); + const closed = new Promise((resolve) => child.once("close", () => resolve())); + if (!(internals.ready instanceof Promise)) { + child.kill("SIGKILL"); + await bounded(closed, "invalid SDK shape driver reap"); + throw new Error("Terminal Control private ready shape changed"); + } + const owner: OwnedDriver = { + terminal, + ready: internals.ready, + child, + closed, + hostGroups: new Set(), + frozen: false, + reaped: false, + }; + const abort = internals.abort.bind(terminal); + internals.abort = (error) => { + try { + retainHosts(owner); + } catch (discoveryError) { + owner.discoveryError = discoveryError; + } finally { + abort(error); + } + }; + // A rejected hello must be observed even if cleanup begins before awaiting it. + void owner.ready.catch(() => {}); + return owner; +} + +async function confirmReaped(owner: OwnedDriver, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + while (processes().some((row) => owner.hostGroups.has(row.group))) { + if (Date.now() >= deadline) throw new Error(`forced PTY group reap exceeded ${timeoutMs}ms`); + await Bun.sleep(10); + } + if (owner.discoveryError) throw owner.discoveryError; + owner.reaped = true; +} + +export async function terminateDriver(owner: OwnedDriver, timeoutMs = 5_000) { + retainHosts(owner); + // Kill the PTY groups BEFORE the driver, not just its PID: killing the driver + // alone bypasses Rust Session::drop and can leave an OpenCode host alive. + for (const group of owner.hostGroups) signal(-group, "SIGKILL"); + // SIGKILL also terminates stopped processes; do not resume and reopen a spawn race. + owner.child.kill("SIGKILL"); + await bounded(owner.closed, "forced driver reap", timeoutMs); + await confirmReaped(owner, timeoutMs); +} + +export async function closeDriver(owner: OwnedDriver, timeoutMs = 5_000) { + try { + await bounded(owner.terminal.close(), "driver shutdown", timeoutMs); + await bounded(owner.closed, "driver reap", timeoutMs); + // SDK close becomes a no-op after abort: retries must still verify retained ownership. + await confirmReaped(owner, timeoutMs); + } catch (error) { + await terminateDriver(owner, timeoutMs); + throw error; + } +} diff --git a/test/e2e/fixture/package.json b/test/e2e/fixture/package.json new file mode 100644 index 0000000..ac64947 --- /dev/null +++ b/test/e2e/fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "vimcode-e2e-question-fixture", + "private": true, + "type": "module", + "exports": { + "./tui": "./tui.ts" + } +} diff --git a/test/e2e/fixture/tui.ts b/test/e2e/fixture/tui.ts new file mode 100644 index 0000000..d90a79d --- /dev/null +++ b/test/e2e/fixture/tui.ts @@ -0,0 +1,107 @@ +type Context = { + options: { directory: string }; + keymap: { + layer( + input: () => { + mode: "global"; + commands: Array<{ + id: string; + title: string; + group: string; + palette: true; + bind: string; + run(): Promise; + }>; + }, + ): void; + }; + ui: { + slot(input: { append: "app"; render(): null }): () => void; + router: { navigate(route: { type: "session"; sessionID: string }): void }; + }; + client: { + session: { + create(input: { title: string; location: { directory: string } }): Promise<{ id: string }>; + form: { + create(input: { + sessionID: string; + title: string; + fields: Array<{ key: string; type: "string"; title: string; required: true }>; + }): Promise; + }; + }; + }; +}; + +type V1Api = { + keymap: { + registerLayer(input: { + commands: Array<{ name: string; title: string; run(): Promise }>; + bindings: Array<{ key: string; cmd: string }>; + }): void; + }; + route: { navigate(name: "session", params: { sessionID: string }): void }; + client: { + session: { + create( + input: { directory: string; title: string }, + options: { throwOnError: true }, + ): Promise<{ data: { id: string } }>; + }; + }; +}; + +// Sets up a real question without a model call or reading/mutating editor state. +export default { + id: "vimcode-e2e-question-fixture", + async tui(api: V1Api, options: { directory: string }) { + api.keymap.registerLayer({ + commands: [ + { + name: "vimcode.e2e.question", + title: "Open synthetic question", + async run() { + const session = await api.client.session.create( + { directory: options.directory, title: "Vimcode E2E session" }, + { throwOnError: true }, + ); + api.route.navigate("session", { sessionID: session.data.id }); + }, + }, + ], + bindings: [{ key: "ctrl+g", cmd: "vimcode.e2e.question" }], + }); + }, + setup(context: Context) { + return context.ui.slot({ + append: "app", + render() { + context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "vimcode.e2e.question", + title: "Open synthetic question", + group: "E2E", + palette: true, + bind: "ctrl+g", + async run() { + const session = await context.client.session.create({ + title: "Vimcode E2E session", + location: { directory: context.options.directory }, + }); + context.ui.router.navigate({ type: "session", sessionID: session.id }); + await context.client.session.form.create({ + sessionID: session.id, + title: "Vimcode E2E question", + fields: [{ key: "answer", type: "string", title: "Answer", required: true }], + }); + }, + }, + ], + })); + return null; + }, + }); + }, +}; diff --git a/test/e2e/fixture/v1-server.ts b/test/e2e/fixture/v1-server.ts new file mode 100644 index 0000000..d25aab7 --- /dev/null +++ b/test/e2e/fixture/v1-server.ts @@ -0,0 +1,26 @@ +// Runs inside v1's server worker so the native question uses its real event bus. +export default { + id: "vimcode-e2e-v1-question-server", + async server(input: { directory: string }, options: { v1Bridge: string }) { + return { + async event({ event }: { event: { type: string; properties: { info?: { id: string; title: string } } } }) { + const session = event.properties.info; + if (event.type !== "session.created" || session?.title !== "Vimcode E2E session") return; + const { AppRuntime, InstanceStore, Question } = await import(options.v1Bridge); + await AppRuntime.runPromise( + InstanceStore.Service.use((store: { provide(input: object, effect: unknown): unknown }) => + store.provide( + { directory: input.directory }, + Question.Service.use((question: { ask(input: object): unknown }) => + question.ask({ + sessionID: session.id, + questions: [{ header: "Answer", question: "Vimcode E2E question", options: [] }], + }), + ), + ), + ), + ).catch(() => {}); + }, + }; + }, +}; diff --git a/test/e2e/host.ts b/test/e2e/host.ts new file mode 100644 index 0000000..24db92e --- /dev/null +++ b/test/e2e/host.ts @@ -0,0 +1,492 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type Key, + resolveTerminalControlBinary, + type ScreenSnapshot, + type Session, + type TerminalControl, +} from "@kitlangton/terminal-control"; +import { bounded, closeDriver, type OwnedDriver, ownDriver } from "./driver"; +import { + promptMatches, + providerDisabledPrompt, + type VisiblePrompt, + visibleAnswer, + visiblePrompt, + visibleV1AnswerChoice, +} from "./prompt"; +import { v1QuestionBridge } from "./v1-question"; + +const root = realpathSync(fileURLToPath(new URL("../../", import.meta.url))); +const configNames = [ + ".git", + ".opencode", + "opencode.json", + "opencode.jsonc", + "tui.json", + "tui.jsonc", + "cli.json", + "cli.jsonc", +]; + +type Check = { name: string; status: "pass" | "fail"; expected?: unknown; actual?: unknown; error?: string }; +type Receipt = { + host: "v1" | "v2"; + binary: string; + version: string; + platform: string; + terminalControl: string; + driverBinary?: string; + questionSetupModules?: Array<{ path: string; sha256: string }>; + negativeControl: boolean; + providersDisabled?: boolean; + providerObservations?: Record; + artifact?: { tarball: string; sha256: string; installed?: string; verifiedFiles?: Record }; + checks: Check[]; + cleanupErrors: string[]; +}; + +export type HostRun = { + output: string; + env: Record; + receipt: Receipt; + terminal?: TerminalControl; + driver?: OwnedDriver; + session?: Session; + scenario?: string; + hostLogOffset?: number; +}; + +export function saveReceipt(run: HostRun) { + writeFileSync(join(run.output, "receipt.json"), `${JSON.stringify(run.receipt, null, 2)}\n`); +} + +export function prepareRun(): HostRun { + if (!["darwin", "linux"].includes(process.platform)) + throw new Error("Terminal Control E2E requires macOS or GNU/Linux"); + const host = process.env.VIMCODE_E2E_HOST; + const binary = process.env.VIMCODE_E2E_BIN; + const version = process.env.VIMCODE_E2E_VERSION; + const outputArg = process.env.VIMCODE_E2E_OUTPUT; + if ( + (host !== "v1" && host !== "v2") || + !binary || + !version || + !outputArg || + !isAbsolute(binary) || + !isAbsolute(outputArg) + ) { + throw new Error("Use just test-e2e v1|v2 /absolute/opencode expected-version /absolute/new-output-directory"); + } + const output = join(realpathSync(dirname(outputArg)), outputArg.slice(dirname(outputArg).length + 1)); + for (let path = output; ; path = dirname(path)) { + if (configNames.some((name) => existsSync(join(path, name)))) + throw new Error(`E2E output must be outside project/config ancestry: ${path}`); + if (dirname(path) === path) break; + } + if (existsSync(output) && readdirSync(output).length) throw new Error(`E2E output must be empty: ${output}`); + mkdirSync(output, { recursive: true }); + const canonicalOutput = realpathSync(output); + for (let path = canonicalOutput; ; path = dirname(path)) { + if (configNames.some((name) => existsSync(join(path, name)))) + throw new Error(`E2E output must be outside project/config ancestry: ${path}`); + if (dirname(path) === path) break; + } + for (const folder of ["home", "config/opencode", "data", "cache", "state", "sandbox", "bin", "build", "reference"]) { + mkdirSync(join(canonicalOutput, folder), { recursive: true }); + } + const toolDirectories = [process.execPath, Bun.which("npm"), Bun.which("node")] + .filter((path): path is string => path !== null) + .map((path) => dirname(path)); + const env = { + PATH: [ + ...new Set([ + join(canonicalOutput, "bin"), + ...toolDirectories, + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]), + ].join(":"), + HOME: join(canonicalOutput, "home"), + XDG_CONFIG_HOME: join(canonicalOutput, "config"), + XDG_DATA_HOME: join(canonicalOutput, "data"), + XDG_CACHE_HOME: join(canonicalOutput, "cache"), + XDG_STATE_HOME: join(canonicalOutput, "state"), + TERM: "xterm-256color", + COLORTERM: "truecolor", + OPENCODE_DISABLE_AUTOUPDATE: "1", + }; + for (const name of ["pbcopy", "wl-copy", "xclip", "xsel", "clip.exe"]) { + writeFileSync(join(canonicalOutput, "bin", name), "#!/bin/sh\ncat >/dev/null\n", { mode: 0o755 }); + } + for (const name of ["pbpaste", "wl-paste"]) { + writeFileSync(join(canonicalOutput, "bin", name), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + } + const run: HostRun = { + output: canonicalOutput, + env, + receipt: { + host, + binary, + version, + platform: `${process.platform}-${process.arch}`, + terminalControl: "1.2.1", + negativeControl: process.env.VIMCODE_E2E_NO_PLUGIN === "1", + checks: [], + cleanupErrors: [], + }, + }; + saveReceipt(run); + try { + if (process.env.TERMCTRL_BINARY) { + throw new Error( + "TERMCTRL_BINARY override is unsupported: unset it to use the pinned Terminal Control 1.2.1 packaged driver", + ); + } + run.receipt.driverBinary = realpathSync(resolveTerminalControlBinary()); + writeFileSync( + join(run.output, "config/opencode/opencode.json"), + JSON.stringify({ autoupdate: false, enabled_providers: [] }), + ); + const actualBinary = realpathSync(binary); + const actualVersion = execFileSync(actualBinary, ["--version"], { env, encoding: "utf8", timeout: 10_000 }) + .trim() + .replace(/^opencode v/, ""); + Object.assign(run.receipt, { binary: actualBinary, version: actualVersion }); + if (actualVersion !== version || !version.startsWith(host === "v1" ? "1." : "2.")) + throw new Error(`Expected OpenCode ${host} ${version}, got ${actualVersion}`); + const packed = JSON.parse( + execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", join(run.output, "build")], { + cwd: root, + env, + encoding: "utf8", + timeout: 30_000, + }), + ) as Array<{ filename: string; files: Array<{ path: string }> }>; + if ( + packed.length !== 1 || + packed[0].files.some((file) => file.path.startsWith("test/") || file.path.startsWith("scripts/")) + ) { + throw new Error("Expected one package without test/launcher fixtures"); + } + const tarball = join(run.output, "build", packed[0].filename); + execFileSync("tar", ["-xzf", tarball, "-C", join(run.output, "reference")], { env, timeout: 10_000 }); + run.receipt.artifact = { tarball, sha256: hash(readFileSync(tarball)) }; + const spec = `vimcode@file:${tarball}`; + const options = { updateCheck: false, modeIndicator: "none", experimentalV2Leader: "ctrl+x" }; + let v1Bridge: string | undefined; + if (host === "v1" && !run.receipt.negativeControl) { + const bridge = v1QuestionBridge(readFileSync(actualBinary)); + v1Bridge = join(run.output, "v1-question-bridge.ts"); + writeFileSync(v1Bridge, bridge.source); + run.receipt.questionSetupModules = bridge.modules; + // The plain setup fixture has no npm imports; bound v1's optional config + // dependency install so an unavailable registry cannot stall startup. + writeFileSync(join(run.output, "config/opencode/.npmrc"), "fetch-retries=0\nfetch-timeout=1000\n"); + writeFileSync( + join(run.output, "config/opencode/opencode.json"), + JSON.stringify({ + autoupdate: false, + enabled_providers: [], + plugin: [[join(root, "test/e2e/fixture/v1-server.ts"), { v1Bridge }]], + }), + ); + } + const config = + host === "v1" + ? { + plugin: run.receipt.negativeControl + ? [] + : [ + [spec, options], + [join(root, "test/e2e/fixture/tui.ts"), { directory: join(run.output, "sandbox"), v1Bridge }], + ], + keybinds: { leader: "ctrl+x" }, + } + : { + plugins: [ + ...(run.receipt.negativeControl ? [] : [{ package: spec, options }]), + { package: join(root, "test/e2e/fixture"), options: { directory: join(run.output, "sandbox") } }, + ], + keybinds: { leader: "ctrl+x" }, + }; + writeFileSync(join(run.output, `config/opencode/${host === "v1" ? "tui" : "cli"}.json`), JSON.stringify(config)); + saveReceipt(run); + return run; + } catch (error) { + run.receipt.checks.push({ name: "setup", status: "fail", error: String(error) }); + saveReceipt(run); + throw error; + } +} + +function hash(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function verifyInstalledArtifact(run: HostRun) { + if (run.receipt.negativeControl) return; + const cache = join(run.output, "cache/opencode"); + const entries = [...new Bun.Glob("**/node_modules/vimcode/package.json").scanSync({ cwd: cache, absolute: true })]; + if (entries.length !== 1) throw new Error(`Expected exactly one cache-installed vimcode, found ${entries.length}`); + const installed = dirname(entries[0]); + const reference = join(run.output, "reference/package"); + const files = ["package.json", ...sourceFiles(join(reference, "src")).map((path) => `src/${path}`)]; + const verifiedFiles: Record = {}; + for (const relative of files) { + const expected = readFileSync(join(reference, relative)); + if (!expected.equals(readFileSync(join(installed, relative)))) + throw new Error(`Cache artifact mismatch: ${relative}`); + verifiedFiles[relative] = hash(expected); + } + if (!run.receipt.artifact) throw new Error("Package identity missing"); + Object.assign(run.receipt.artifact, { installed, verifiedFiles }); + saveReceipt(run); +} + +function sourceFiles(directory: string, prefix = ""): string[] { + return readdirSync(directory).flatMap((name) => { + const relative = join(prefix, name); + return statSync(join(directory, name)).isDirectory() ? sourceFiles(join(directory, name), relative) : [relative]; + }); +} + +export async function startScenario(run: HostRun, name: string) { + if (run.driver) throw new Error("Previous owned driver has not been reaped; refusing another launch"); + run.scenario = name; + mkdirSync(join(run.output, name)); + const hostLog = join(run.env.XDG_DATA_HOME ?? run.output, "opencode/log/opencode.log"); + run.hostLogOffset = existsSync(hostLog) ? statSync(hostLog).size : 0; + // The driver also gets no provider credentials; launch independently rejects inheritance. + const driverEnv = Object.fromEntries(Object.keys(process.env).map((key) => [key, undefined])); + Object.assign(driverEnv, run.env); + run.driver = await ownDriver({ binaryPath: run.receipt.driverBinary, cwd: run.output, env: driverEnv }); + run.terminal = run.driver.terminal; + await bounded(run.driver.ready, "driver startup", 10_000); + run.session = await bounded( + run.terminal.launch({ + command: [ + run.receipt.binary, + ...(run.receipt.host === "v2" ? ["--standalone"] : []), + join(run.output, "sandbox"), + ], + cwd: join(run.output, "sandbox"), + host: "opentui", + viewport: { cols: 110, rows: 34 }, + inheritEnv: false, + env: { + ...run.env, + ...(run.receipt.host === "v2" ? { OPENCODE_LOG_LEVEL: "error" } : {}), + }, + record: "on-failure", + }), + "host launch", + 10_000, + ); + const deadline = Date.now() + 30_000; + const connectionDialog = (snapshot: ScreenSnapshot) => + /^\s+Connect a provider\s+esc\s*$/m.test(snapshot.text) && + /^\s+Search\s*$/m.test(snapshot.text) && + /^\s+Other Custom provider\s*$/m.test(snapshot.text); + const ready = (snapshot: ScreenSnapshot) => { + const prompt = visiblePrompt(snapshot); + return ( + providerDisabledPrompt(snapshot) && + prompt?.lines.length === 1 && + (prompt.lines[0] === "Ask" || prompt.lines[0].startsWith("Ask anything")) + ); + }; + let capture = await waitVisible(run, (snapshot) => connectionDialog(snapshot) || ready(snapshot), 30_000); + // Disabled providers trigger this setup overlay on v1. Never approve or submit it. + if (connectionDialog(capture)) { + await pressKey(run, "Escape"); + capture = await waitVisible( + run, + (snapshot) => !connectionDialog(snapshot) && ready(snapshot), + deadline - Date.now(), + ); + } + if (!ready(capture)) throw new Error("Provider isolation failed: no provider-free home prompt observed"); + run.receipt.providerObservations ??= {}; + run.receipt.providerObservations[name] = "Build · No provider selected Connect a provider"; + run.receipt.providersDisabled = true; + writeFileSync(join(run.output, name, "provider-disabled.txt"), capture.text); + saveReceipt(run); +} + +// Native capture can report idle immediately based on output from BEFORE the last +// input. Observe stability after this call too, so Escape is decoded before 0/w. +async function waitVisible(run: HostRun, predicate: (snapshot: ScreenSnapshot) => boolean, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + const settleMs = 250; + let previous = ""; + let stableSince = Date.now(); + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + const snapshot = await bounded( + sessionFor(run).screen.capture({ + allowIncomplete: true, + settleMs, + deadlineMs: Math.min(1_000, remaining), + }), + "visible screen capture", + remaining, + ); + if (snapshot.reason === "exited" || snapshot.reason === "outputclosed") + throw new Error(`OpenCode capture ended with ${snapshot.reason}`); + const current = JSON.stringify({ text: snapshot.text, cursor: snapshot.frame.cursor }); + const matches = predicate(snapshot); + if (current !== previous || snapshot.reason !== "idle" || !matches) stableSince = Date.now(); + previous = current; + if (matches && snapshot.reason === "idle" && Date.now() - stableSince >= settleMs) return snapshot; + // Polling cadence, not a substitute for an observed settled frame. + await Bun.sleep(Math.min(10, Math.max(0, deadline - Date.now()))); + } + throw new Error("Timed out waiting for a settled visible prompt"); +} + +export function sessionFor(run: HostRun): Session { + if (!run.session) throw new Error("Host session missing"); + return run.session; +} + +export async function typeText(run: HostRun, text: string, timeoutMs = 5_000) { + await bounded(sessionFor(run).keyboard.type(text), "keyboard type", timeoutMs); +} + +export async function pressKey(run: HostRun, key: Key, timeoutMs = 5_000) { + await bounded(sessionFor(run).keyboard.press(key), "keyboard press", timeoutMs); +} + +export async function writeInput(run: HostRun, text: string, timeoutMs = 5_000) { + await bounded(sessionFor(run).keyboard.write(new TextEncoder().encode(text)), "keyboard raw write", timeoutMs); +} + +export async function openQuestion(run: HostRun) { + await pressKey(run, "Control+G"); + if (run.receipt.host === "v1") { + await checkpoint(run, "answer-choice", ["Type your own answer"], undefined, visibleV1AnswerChoice); + await pressKey(run, "Enter"); + } +} + +export async function answerCheckpoint(run: HostRun, name: string, text: string) { + await checkpoint(run, name, [text], undefined, visibleAnswer); +} + +export async function checkpoint( + run: HostRun, + name: string, + lines: string[], + cursor?: { x: number; y: number }, + readPrompt = visiblePrompt, +) { + let actual: VisiblePrompt | null = null; + try { + const capture = await waitVisible( + run, + (snapshot) => { + actual = readPrompt(snapshot); + return promptMatches(actual, lines, cursor); + }, + 5_000, + ); + run.receipt.checks.push({ name, status: "pass", expected: { lines, cursor }, actual }); + writeFileSync(join(run.output, run.scenario ?? "", `${name}.txt`), capture.text); + saveReceipt(run); + } catch (error) { + const message = `Checkpoint ${name}: expected ${JSON.stringify({ lines, cursor })}, last visible prompt ${JSON.stringify(actual)}`; + run.receipt.checks.push({ name, status: "fail", expected: { lines, cursor }, actual, error: message }); + saveReceipt(run); + throw new Error(message, { cause: error }); + } +} + +export async function saveFailure(run: HostRun, error: unknown) { + run.receipt.checks.push({ + name: run.scenario ?? "setup", + status: "fail", + error: error instanceof Error ? error.message : String(error), + }); + if (run.session) { + try { + const capture: ScreenSnapshot = await bounded( + run.session.screen.capture({ allowIncomplete: true, settleMs: 0, deadlineMs: 0 }), + "failure capture", + ); + const directory = join(run.output, run.scenario ?? ""); + writeFileSync(join(directory, "failure.txt"), capture.text); + writeFileSync(join(directory, "failure.json"), JSON.stringify(capture, null, 2)); + await bounded(run.session.saveRecording(join(directory, "failure.termctrl")), "failure recording"); + if (capture.reason === "exited" || capture.reason === "outputclosed") { + const { state, exit } = await bounded(run.session.status(), "failure exit status"); + writeFileSync(join(directory, "failure-status.json"), JSON.stringify({ state, exit }, null, 2)); + } + } catch (captureError) { + run.receipt.checks.push({ name: "failure-evidence", status: "fail", error: String(captureError) }); + } + } + try { + const hostLog = join(run.env.XDG_DATA_HOME ?? run.output, "opencode/log/opencode.log"); + if (existsSync(hostLog)) { + const log = readFileSync(hostLog); + const offset = (run.hostLogOffset ?? 0) <= log.length ? (run.hostLogOffset ?? 0) : 0; + const errors = log + .subarray(offset) + .toString() + .split("\n") + .filter((line) => /level=(?:ERROR|FATAL)|"level":"(?:error|fatal)"/.test(line)); + if (errors.length) + writeFileSync(join(run.output, run.scenario ?? "", "host-error.log"), `${errors.join("\n")}\n`); + } + } catch (logError) { + run.receipt.checks.push({ name: "failure-evidence", status: "fail", error: String(logError) }); + } + saveReceipt(run); +} + +export async function stopScenario(run: HostRun) { + const errors: string[] = []; + if (run.session) { + try { + await bounded(run.session.stop(), "host stop"); + } catch (error) { + errors.push(String(error)); + } + } + if (run.driver) { + try { + await closeDriver(run.driver); + } catch (error) { + errors.push(String(error)); + } + // Never forget a live handle if the forced reap itself failed. + if (run.driver.reaped) { + run.session = undefined; + run.terminal = undefined; + run.driver = undefined; + } + } + run.receipt.cleanupErrors.push(...errors); + saveReceipt(run); + if (errors.length) throw new Error(`Owned E2E session cleanup failed: ${errors.join("; ")}`); +} + +export async function scenario(run: HostRun, name: string, test: () => Promise) { + try { + await startScenario(run, name); + await test(); + } catch (error) { + await saveFailure(run, error); + throw error; + } finally { + await stopScenario(run); + } +} diff --git a/test/e2e/launcher.integration.ts b/test/e2e/launcher.integration.ts new file mode 100644 index 0000000..22c46ea --- /dev/null +++ b/test/e2e/launcher.integration.ts @@ -0,0 +1,210 @@ +import { expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + binarySettings, + freshRunRoot, + isolatedToolEnv, + nativeSource, + outputPath, + pins, + resolveBinary, + runHosts, + selectHosts, + verifiedBinary, +} from "../../scripts/test-e2e"; + +function fixture() { + return mkdtempSync(join(realpathSync(tmpdir()), "vimcode-launcher-probe-")); +} + +it("defaults to both exact pins, supports one host and existing four-argument CI calls", () => { + expect(pins).toEqual({ v1: "1.18.34", v2: "2.0.15" }); + expect(selectHosts([])).toEqual({ hosts: ["v1", "v2"] }); + expect(selectHosts(["", "", "", ""])).toEqual({ hosts: ["v1", "v2"] }); + for (const host of ["v1", "v2"] as const) { + expect(selectHosts([host, "", "", ""])).toEqual({ hosts: [host] }); + expect(selectHosts([host, "/native/opencode", pins[host], "/outside/output"])).toEqual({ + hosts: [host], + explicit: { binary: "/native/opencode", version: pins[host], output: "/outside/output" }, + }); + } +}); + +it("rejects bad selectors, incomplete explicit arguments, relative CI paths and unpinned versions", () => { + for (const args of [ + ["v3"], + ["v1", "/binary"], + ["", "/binary", pins.v1, "/output"], + ["v2", "/binary", pins.v2, "/output", "extra"], + ]) + expect(() => selectHosts(args)).toThrow("Use just test-e2e"); + for (const args of [ + ["v1", "relative", pins.v1, "/output"], + ["v2", "/binary", "latest", "/output"], + ]) + expect(() => selectHosts(args)).toThrow("requires absolute binary/output paths and pinned version"); +}); + +it("parses synthetic dotenv as data, with process precedence, only path keys, and CI file exclusion", () => { + const text = `# synthetic, never a user's file +export VIMCODE_E2E_V1_BIN="./host one" # comment +VIMCODE_E2E_V2_BIN='./$(touch must-not-run)' +SYNTHETIC_PROVIDER_SECRET=do-not-forward +OPENCODE_CONFIG_CONTENT=do-not-forward +VIMCODE_E2E_VERSION=latest +UNKNOWN_KEY=do-not-forward +`; + expect(binarySettings({}, text)).toEqual({ v1: "./host one", v2: "./$(touch must-not-run)" }); + expect(binarySettings({ VIMCODE_E2E_V1_BIN: "/process/host" }, text)).toEqual({ + v1: "/process/host", + v2: "./$(touch must-not-run)", + }); + expect(binarySettings({ CI: "true" }, text)).toEqual({}); + expect(binarySettings({ CI: "1", VIMCODE_E2E_V2_BIN: "/ci/host" }, text)).toEqual({ v2: "/ci/host" }); + expect(binarySettings({ VIMCODE_E2E_V1_BIN: "" }, text)).toEqual({ v1: "", v2: "./$(touch must-not-run)" }); + expect(binarySettings({}, 'VIMCODE_E2E_V1_BIN="line\\nnext"')).toEqual({ v1: "line\nnext" }); +}); + +it("Bun --env-file=/dev/null really excludes synthetic local secrets from the launched child", () => { + const directory = fixture(); + try { + writeFileSync(join(directory, ".env"), "SYNTHETIC_PROVIDER_SECRET=do-not-forward\nVIMCODE_E2E_V1_BIN=./host\n"); + const env = isolatedToolEnv(join(directory, "tools")); + const probe = + "process.stdout.write(JSON.stringify({secret:process.env.SYNTHETIC_PROVIDER_SECRET,bin:process.env.VIMCODE_E2E_V1_BIN}))"; + const autoload = Bun.spawnSync([process.execPath, "-e", probe], { cwd: directory, env, stdout: "pipe" }); + expect(autoload.exitCode).toBe(0); + expect(JSON.parse(new TextDecoder().decode(autoload.stdout))).toEqual({ secret: "do-not-forward", bin: "./host" }); + const isolated = Bun.spawnSync([process.execPath, "--env-file=/dev/null", "-e", probe], { + cwd: directory, + env, + stdout: "pipe", + }); + expect(isolated.exitCode).toBe(0); + expect(JSON.parse(new TextDecoder().decode(isolated.stdout))).toEqual({}); + expect(env).not.toHaveProperty("SYNTHETIC_PROVIDER_SECRET"); + expect(env).not.toHaveProperty("VIMCODE_E2E_V1_BIN"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("infers unique canonical OS-temp outputs and rejects nonempty/config ancestors", () => { + const first = freshRunRoot(); + const second = freshRunRoot(); + try { + expect(first).not.toBe(second); + expect(first.startsWith(join(realpathSync(tmpdir()), "vimcode-e2e-"))).toBe(true); + expect(outputPath(join(first, "v1"))).toBe(join(first, "v1")); + writeFileSync(join(first, "evidence.txt"), "preserved"); + expect(() => outputPath(first)).toThrow("new or empty"); + mkdirSync(join(second, "project")); + mkdirSync(join(second, "project/.git")); + expect(() => outputPath(join(second, "project/new"))).toThrow("outside project/config ancestry"); + symlinkSync(join(second, "project"), join(first, "linked")); + expect(() => outputPath(join(first, "linked/new"))).toThrow("outside project/config ancestry"); + } finally { + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } +}); + +it("binary overrides resolve relative to the repository base, canonicalize, and reject missing/wrong versions", () => { + const directory = fixture(); + try { + const env = isolatedToolEnv(join(directory, "tools")); + const binary = join(directory, "native"); + writeFileSync(binary, "#!/bin/sh\nprintf 'opencode v1.18.34\\n'\n", { mode: 0o755 }); + symlinkSync(binary, join(directory, "link")); + expect(verifiedBinary("./link", "v1", directory, env, directory)).toBe(binary); + expect(() => verifiedBinary("./missing", "v1", directory, env, directory)).toThrow("binary path does not exist"); + expect(() => verifiedBinary(binary, "v2", directory, env)).toThrow("Expected OpenCode v2 2.0.15"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("invalid selected-host settings fail without blocking another host or an unrelated selector", async () => { + const directory = fixture(); + try { + const env = isolatedToolEnv(join(directory, "tools")); + const binary = join(directory, "v2-native"); + writeFileSync(binary, "#!/bin/sh\nprintf 'opencode v2.0.15\\n'\n", { mode: 0o755 }); + for (const invalid of ["", " ", "line\nnext"]) { + const settings = binarySettings({ VIMCODE_E2E_V1_BIN: invalid, VIMCODE_E2E_V2_BIN: binary }); + const completed: string[] = []; + const execute = async (host: "v1" | "v2") => { + try { + resolveBinary(host, settings[host], directory, env); + completed.push(host); + return 0; + } catch (error) { + expect(String(error)).toContain("VIMCODE_E2E_V1_BIN must be a nonempty binary path"); + return 1; + } + }; + expect(await runHosts(selectHosts([]).hosts, execute)).toBe(1); + expect(completed).toEqual(["v2"]); + completed.length = 0; + expect(await runHosts(selectHosts(["v2"]).hosts, execute)).toBe(0); + expect(completed).toEqual(["v2"]); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("version probes are bounded even when an override stalls", () => { + const directory = fixture(); + try { + const env = isolatedToolEnv(join(directory, "tools")); + const binary = join(directory, "stalled"); + writeFileSync(binary, `#!${process.execPath}\nprocess.on("SIGTERM", () => {});\nsetInterval(() => {}, 1000);\n`, { + mode: 0o755, + }); + const start = Date.now(); + expect(() => verifiedBinary(binary, "v1", directory, env)).toThrow("ETIMEDOUT"); + expect(Date.now() - start).toBeLessThan(15_000); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}, 20_000); + +it("only bootstraps verified native shapes and gives unsupported platforms an actionable override", () => { + expect(nativeSource("v1", "darwin", "arm64")).toEqual({ + asset: "opencode-darwin-arm64.zip", + sha256: "8522b70f545184b3a8d97c5ca4f814093b2476d72aebfda8c48bcd072ec31d1b", + }); + expect(nativeSource("v1", "linux", "x64")).toEqual({ + asset: "opencode-linux-x64-baseline.tar.gz", + sha256: "24b0d458d21ef548b2752166303defcf7f4945b049fb4876ab78dfaf86d81b27", + }); + expect(nativeSource("v2", "darwin", "arm64")).toEqual({ package: "@opencode/cli-darwin-arm64" }); + expect(nativeSource("v2", "linux", "x64")).toEqual({ package: "@opencode/cli-linux-x64-baseline" }); + expect(() => nativeSource("v2", "linux", "arm64")).toThrow("set VIMCODE_E2E_V2_BIN"); + expect(() => nativeSource("v1", "win32", "x64")).toThrow("No verified bootstrap"); +}); + +it("both-host execution is sequential, continues after failure, and never turns negative exit 1 green", async () => { + const visited: string[] = []; + expect( + await runHosts(["v1", "v2"], async (host) => { + visited.push(host); + const child = Bun.spawn( + [process.execPath, "--env-file=/dev/null", "-e", `process.exit(${host === "v1" ? 1 : 0})`], + { + env: {}, + stdout: "ignore", + stderr: "ignore", + }, + ); + return child.exited; + }), + ).toBe(1); + expect(visited).toEqual(["v1", "v2"]); + expect(await runHosts(["v1", "v2"], async () => 0)).toBe(0); + expect(await runHosts(["v2"], async () => 1)).toBe(1); + expect(await runHosts(["v1", "v2"], async (host) => (host === "v2" ? 1 : 0))).toBe(1); +}); diff --git a/test/e2e/opencode.e2e.ts b/test/e2e/opencode.e2e.ts new file mode 100644 index 0000000..e151dcb --- /dev/null +++ b/test/e2e/opencode.e2e.ts @@ -0,0 +1,136 @@ +import { beforeAll, describe, it } from "bun:test"; +import { + answerCheckpoint, + checkpoint, + type HostRun, + openQuestion, + prepareRun, + pressKey, + scenario, + typeText, + verifyInstalledArtifact, + writeInput, +} from "./host"; + +const negativeControl = process.env.VIMCODE_E2E_NO_PLUGIN === "1"; +// Leave time for bounded sends/captures and finally cleanup before Bun's outer timeout. +const scenarioTimeoutMs = 240_000; + +describe("Terminal Control / cache-installed OpenCode", () => { + let run: HostRun; + beforeAll(() => { + run = prepareRun(); + }, 60_000); + + it.skipIf(negativeControl)( + "cold tarball load and text insertion", + async () => { + await scenario(run, "cold-load", async () => { + verifyInstalledArtifact(run); + await typeText(run, "cold package loaded"); + await checkpoint(run, "cold-insert", ["cold package loaded"]); + }); + }, + scenarioTimeoutMs, + ); + + it( + "Escape / 0 / w / i / X edits exactly at the next word", + async () => { + await scenario(run, "word-motion", async () => { + await typeText(run, "one two three"); + await checkpoint(run, "word-insert", ["one two three"]); + await pressKey(run, "Escape"); + await checkpoint(run, "word-escape", ["one two three"]); + await typeText(run, "0"); + await checkpoint(run, "word-home", ["one two three"]); + await typeText(run, "w"); + await checkpoint(run, "word-next", ["one two three"]); + await typeText(run, "i"); + await checkpoint(run, "word-insert-mode", ["one two three"]); + await typeText(run, "X"); + // Cursor-only SDK frames can be stale; the editing effect proves 0/w placement. + await checkpoint(run, "word-result", ["one Xtwo three"]); + }); + }, + scenarioTimeoutMs, + ); + + it.skipIf(negativeControl)( + "ci quote replaces only the quoted contents", + async () => { + await scenario(run, "quote-change", async () => { + await typeText(run, 'say "old words" now'); + await checkpoint(run, "quote-insert", ['say "old words" now']); + await pressKey(run, "Escape"); + await checkpoint(run, "quote-escape", ['say "old words" now']); + await typeText(run, "0"); + await checkpoint(run, "quote-home", ['say "old words" now']); + await typeText(run, "w"); + await checkpoint(run, "quote-next", ['say "old words" now']); + await typeText(run, 'ci"'); + await checkpoint(run, "quote-empty", ['say "" now']); + await typeText(run, "new"); + await checkpoint(run, "quote-result", ['say "new" now']); + }); + }, + scenarioTimeoutMs, + ); + + it.skipIf(negativeControl)( + "native paste waits for queued append in a question answer", + async () => { + await scenario(run, "answer-paste", async () => { + verifyInstalledArtifact(run); + await openQuestion(run); + await answerCheckpoint( + run, + "answer-ready", + run.receipt.host === "v1" ? "Type your own answer" : "Type your answer", + ); + await typeText(run, "hello world"); + await answerCheckpoint(run, "answer-insert", "hello world"); + await pressKey(run, "Escape"); + await answerCheckpoint(run, "answer-normal", "hello world"); + // One PTY write: no timer turn separates append from native bracketed paste. + await writeInput(run, "a\x1b[200~X\x1b[201~"); + await answerCheckpoint(run, "answer-paste-result", "hello worldX"); + }); + }, + scenarioTimeoutMs, + ); + + it.skipIf(negativeControl)( + "multiline dG and one u restore the entire prompt", + async () => { + await scenario(run, "multiline-undo", async () => { + await typeText(run, "alpha"); + await checkpoint(run, "line-one", ["alpha"]); + // Providers are disabled; also prove Vim handling is live before Enter. + await pressKey(run, "Escape"); + await checkpoint(run, "newline-guard", ["alpha"], { x: 4, y: 0 }); + await typeText(run, "A"); + await checkpoint(run, "newline-insert-mode", ["alpha"], { x: 5, y: 0 }); + await pressKey(run, "Enter"); + await checkpoint(run, "newline-one", ["alpha", ""], { x: 0, y: 1 }); + await typeText(run, "bravo"); + await checkpoint(run, "line-two", ["alpha", "bravo"]); + await pressKey(run, "Enter"); + await checkpoint(run, "newline-two", ["alpha", "bravo", ""], { x: 0, y: 2 }); + await typeText(run, "charlie"); + await checkpoint(run, "line-three", ["alpha", "bravo", "charlie"]); + await pressKey(run, "Escape"); + await checkpoint(run, "multiline-escape", ["alpha", "bravo", "charlie"], { x: 6, y: 2 }); + await typeText(run, "gg"); + await checkpoint(run, "multiline-home", ["alpha", "bravo", "charlie"], { x: 0, y: 0 }); + await typeText(run, "j"); + await checkpoint(run, "multiline-down", ["alpha", "bravo", "charlie"], { x: 0, y: 1 }); + await typeText(run, "dG"); + await checkpoint(run, "multiline-delete", ["alpha", ""], { x: 0, y: 1 }); + await typeText(run, "u"); + await checkpoint(run, "multiline-restored", ["alpha", "bravo", "charlie"], { x: 0, y: 1 }); + }); + }, + scenarioTimeoutMs, + ); +}); diff --git a/test/e2e/prompt.ts b/test/e2e/prompt.ts new file mode 100644 index 0000000..05f7593 --- /dev/null +++ b/test/e2e/prompt.ts @@ -0,0 +1,127 @@ +import type { ScreenSnapshot } from "@kitlangton/terminal-control"; + +export type VisiblePrompt = { + lines: string[]; + origin: { x: number; y: number }; + cursor: { x: number; y: number; style: string } | null; +}; + +// Both pinned hosts use this bordered home prompt; arbitrary terminal text is not an oracle. +// Terminal padding cannot distinguish trailing buffer spaces, so cases use short ASCII lines. +export function visiblePrompt(snapshot: ScreenSnapshot): VisiblePrompt | null { + const rows = snapshot.text.split("\n"); + const metadata = rows.flatMap((row, y) => { + const match = /^( *)┃ {2}(?:Build|Plan) · /.exec(row); + return match ? [{ y, x: match[1].length }] : []; + }); + if (metadata.length !== 1) return null; + const { x, y } = metadata[0]; + const border = `${" ".repeat(x)}┃`; + if (!rows[y + 1]?.startsWith(`${" ".repeat(x)}╹▀`)) return null; + let top = y; + while (top > 0 && rows[top - 1].startsWith(border)) top--; + if (y - top < 3) return null; + const content = (row: string) => row.slice(x + 3).trimEnd(); + if (content(rows[top]) !== "" || content(rows[y - 1]) !== "") return null; + const lines = rows.slice(top + 1, y - 1).map(content); + const origin = { x: x + 3, y: top + 1 }; + const cursor = snapshot.frame.cursor; + const cursorInPrompt = cursor && cursor.x >= origin.x && cursor.y >= origin.y && cursor.y < y - 1; + return { + lines, + origin, + cursor: cursorInPrompt ? { x: cursor.x - origin.x, y: cursor.y - origin.y, style: cursor.style } : null, + }; +} + +export function visibleV1AnswerChoice(snapshot: ScreenSnapshot): VisiblePrompt | null { + const rows = snapshot.text.split("\n"); + const titles = rows.flatMap((row, y) => { + const match = /^( *)┃ {2}Vimcode E2E question *$/.exec(row); + return match ? [{ y, x: match[1].length }] : []; + }); + if (titles.length !== 1) return null; + const { x, y } = titles[0]; + const border = `${" ".repeat(x)}┃`; + if ( + [-1, 1, 3, 5].some((offset) => rows[y + offset]?.trimEnd() !== border) || + rows[y + 2]?.trimEnd() !== `${border} 1. Type your own answer` || + rows[y + 4]?.trimEnd() !== `${border} ↑↓ select enter submit esc dismiss` + ) + return null; + return { lines: ["Type your own answer"], origin: { x: x + 6, y: y + 2 }, cursor: null }; +} + +// Exact one-line answer layouts on the pinned hosts, not timeline text. +export function visibleAnswer(snapshot: ScreenSnapshot): VisiblePrompt | null { + const rows = snapshot.text.split("\n"); + const titles = rows.flatMap((row, y) => { + const match = /^( *)┃ {2}Vimcode E2E question *$/.exec(row); + return match ? [{ y, x: match[1].length }] : []; + }); + if (titles.length !== 1) return null; + const { x, y } = titles[0]; + const border = `${" ".repeat(x)}┃`; + const content = (offset: number) => + rows[y + offset]?.startsWith(border) ? rows[y + offset].slice(x + 1).trimEnd() : undefined; + if ( + rows[y - 1]?.trimEnd() === border && + [1, 4, 6].every((offset) => rows[y + offset]?.trimEnd() === border) && + content(2) === " 1. Type your own answer" && + content(5) === " ↑↓ select enter submit esc dismiss" && + rows[y + 3]?.startsWith(`${border} `) + ) { + const origin = { x: x + 6, y: y + 3 }; + const cursor = snapshot.frame.cursor; + return { + lines: [rows[origin.y].slice(origin.x).trimEnd()], + origin, + cursor: + cursor && cursor.y === origin.y && cursor.x >= origin.x + ? { x: cursor.x - origin.x, y: 0, style: cursor.style } + : null, + }; + } + if ( + rows[y - 1]?.trimEnd() !== border || + [1, 3, 5, 7, 9].some((offset) => rows[y + offset]?.trimEnd() !== border) || + content(2) !== " Answer Submit" || + content(4) !== " Answer" || + content(8) !== " ⇆ tab enter confirm esc dismiss" || + !rows[y + 6]?.startsWith(`${border} `) + ) + return null; + const origin = { x: x + 4, y: y + 6 }; + const cursor = snapshot.frame.cursor; + return { + lines: [rows[origin.y].slice(origin.x).trimEnd()], + origin, + cursor: + cursor && cursor.y === origin.y && cursor.x >= origin.x + ? { x: cursor.x - origin.x, y: 0, style: cursor.style } + : null, + }; +} + +export function providerDisabledPrompt(snapshot: ScreenSnapshot): boolean { + const prompt = visiblePrompt(snapshot); + if (!prompt) return false; + const metadataRow = prompt.origin.y + prompt.lines.length + 1; + return ( + snapshot.text.split("\n")[metadataRow].slice(prompt.origin.x).trimEnd() === + "Build · No provider selected Connect a provider" + ); +} + +export function promptMatches( + prompt: VisiblePrompt | null, + lines: string[], + cursor?: { x: number; y: number }, +): boolean { + return ( + prompt !== null && + prompt.lines.length === lines.length && + prompt.lines.every((line, index) => line === lines[index]) && + (!cursor || (prompt.cursor?.x === cursor.x && prompt.cursor.y === cursor.y)) + ); +} diff --git a/test/e2e/prompt.unit.ts b/test/e2e/prompt.unit.ts new file mode 100644 index 0000000..1271224 --- /dev/null +++ b/test/e2e/prompt.unit.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import type { ScreenSnapshot } from "@kitlangton/terminal-control"; +import { promptMatches, providerDisabledPrompt, visibleAnswer, visiblePrompt, visibleV1AnswerChoice } from "./prompt"; + +function screen(lines: string[], outside = "one Xtwo three"): ScreenSnapshot { + return { + reason: "idle", + text: [outside, " ┃", ...lines.map((line) => ` ┃ ${line}`), " ┃", " ┃ Build · Test model", " ╹▀▀▀▀▀"].join( + "\n", + ), + frame: { + version: 2, + cols: 110, + rows: 34, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { x: 5, y: 2, style: "block", blinking: false, color: { r: 255, g: 255, b: 255 } }, + cells: [], + }, + }; +} + +describe("pinned-v1 custom answer screen oracle", () => { + function answerScreen(text?: string): ScreenSnapshot { + const snapshot = screen([]); + return { + ...snapshot, + text: [ + "hello worldX outside the question", + " ┃", + " ┃ Vimcode E2E question", + " ┃", + " ┃ 1. Type your own answer", + ...(text === undefined ? [] : [` ┃ ${text}`]), + " ┃", + " ┃ ↑↓ select enter submit esc dismiss", + " ┃", + ].join("\n"), + frame: { ...snapshot.frame, cursor: null }, + }; + } + + it("requires the exact custom-choice screen before Enter opens the editor", () => { + const choice = answerScreen(); + expect(promptMatches(visibleV1AnswerChoice(choice), ["Type your own answer"])).toBe(true); + expect(visibleAnswer(choice)).toBeNull(); + expect(visibleV1AnswerChoice(answerScreen("hello worldX"))).toBeNull(); + expect(visibleV1AnswerChoice({ ...choice, text: choice.text.replace("enter submit", "enter toggle") })).toBeNull(); + }); + + it("compares the entire editor line and rejects malformed borders or confirmation screens", () => { + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(true); + expect(promptMatches(visibleAnswer(answerScreen("hello worlXd")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("extra hello worldX")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(false); + const snapshot = answerScreen("hello worldX"); + for (const text of [ + `${snapshot.text}\n ┃ Vimcode E2E question`, + snapshot.text.replace(" ┃ hello", " x hello"), + snapshot.text.replace("1. Type your own answer", "Review"), + snapshot.text.replace("enter submit", "enter confirm"), + ]) + expect(visibleAnswer({ ...snapshot, text })).toBeNull(); + }); +}); + +describe("pinned-v2 scalar answer screen oracle", () => { + function answerScreen(text: string): ScreenSnapshot { + const snapshot = screen([]); + return { + ...snapshot, + text: [ + "hello worldX outside the form", + " ┃", + " ┃ Vimcode E2E question", + " ┃", + " ┃ Answer Submit", + " ┃", + " ┃ Answer", + " ┃", + ` ┃ ${text}`, + " ┃", + " ┃ ⇆ tab enter confirm esc dismiss", + " ┃", + ].join("\n"), + frame: { + ...snapshot.frame, + cursor: { x: 18, y: 8, style: "bar", blinking: false, color: { r: 255, g: 255, b: 255 } }, + }, + }; + } + + it("compares the entire answer, not matching timeline text or a suffix", () => { + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(true); + expect(promptMatches(visibleAnswer(answerScreen("hello worlXd")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("extra hello worldX")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(false); + expect(visibleAnswer(answerScreen("hello worldX"))?.cursor).toEqual({ x: 12, y: 0, style: "bar" }); + }); + + it("rejects ambiguous titles, missing borders and choice/confirmation screens", () => { + const snapshot = answerScreen("hello worldX"); + for (const text of [ + `${snapshot.text}\n ┃ Vimcode E2E question`, + snapshot.text.replace(" ┃ Answer Submit", " x Answer Submit"), + snapshot.text.replace(" ┃ Answer\n", " ┃ Review\n"), + snapshot.text.replace("enter confirm", "enter toggle"), + snapshot.text.replace(" ┃ hello worldX", " ┃ hello worldX"), + snapshot.text.replace(" ┃\n ┃ hello", "missing border\n ┃ hello"), + ]) { + expect(visibleAnswer({ ...snapshot, text })).toBeNull(); + } + }); +}); + +describe("pinned-host home prompt screen oracle", () => { + it("ignores matching text outside the prompt and rejects a matching suffix", () => { + expect(promptMatches(visiblePrompt(screen(["wrong"])), ["one Xtwo three"])).toBe(false); + expect(promptMatches(visiblePrompt(screen(["extra one Xtwo three"])), ["one Xtwo three"])).toBe(false); + }); + + it("compares every line, including empty lines and internal whitespace", () => { + const prompt = visiblePrompt(screen(["alpha beta", "", "gamma"])); + expect(promptMatches(prompt, ["alpha beta", "", "gamma"])).toBe(true); + expect(promptMatches(prompt, ["alpha beta", "", "gamma"])).toBe(false); + expect(promptMatches(prompt, ["alpha beta", "gamma"])).toBe(false); + }); + + it("requires provider-free metadata inside the strict bordered prompt for both empty placeholders", () => { + for (const placeholder of ["Ask", "Ask anything..."]) { + const snapshot = screen([placeholder]); + const disabled = { + ...snapshot, + text: snapshot.text.replace("Build · Test model", "Build · No provider selected Connect a provider"), + }; + expect(promptMatches(visiblePrompt(disabled), [placeholder])).toBe(true); + expect(providerDisabledPrompt(disabled)).toBe(true); + expect(providerDisabledPrompt(snapshot)).toBe(false); + expect( + providerDisabledPrompt({ + ...snapshot, + text: `Build · No provider selected Connect a provider\n${snapshot.text}`, + }), + ).toBe(false); + expect(providerDisabledPrompt({ ...disabled, text: disabled.text.replace("╹", "x") })).toBe(false); + expect(providerDisabledPrompt({ ...disabled, text: `${disabled.text}\n ┃ Build · Duplicate` })).toBe(false); + } + }); + + it("normalizes a visible cursor relative to the prompt", () => { + const prompt = visiblePrompt(screen(["abc"])); + expect(promptMatches(prompt, ["abc"], { x: 0, y: 0 })).toBe(true); + expect(promptMatches(prompt, ["abc"], { x: 1, y: 0 })).toBe(false); + }); + + it("rejects missing or ambiguous borders", () => { + const snapshot = screen(["abc"]); + expect(visiblePrompt({ ...snapshot, text: snapshot.text.replace("╹", "x") })).toBeNull(); + expect(visiblePrompt({ ...snapshot, text: `${snapshot.text}\n ┃ Build · Duplicate` })).toBeNull(); + }); + + it("keeps exact visible text authoritative when cursor metadata is missing or outside the editor", () => { + const snapshot = screen(["abc"]); + if (!snapshot.frame.cursor) throw new Error("fixture cursor missing"); + for (const cursor of [null, { ...snapshot.frame.cursor, y: 0 }]) { + const prompt = visiblePrompt({ ...snapshot, frame: { ...snapshot.frame, cursor } }); + expect(promptMatches(prompt, ["abc"])).toBe(true); + expect(prompt?.cursor).toBeNull(); + expect(promptMatches(prompt, ["abc"], { x: 0, y: 0 })).toBe(false); + } + }); +}); diff --git a/test/e2e/v1-question.ts b/test/e2e/v1-question.ts new file mode 100644 index 0000000..b9346cb --- /dev/null +++ b/test/e2e/v1-question.ts @@ -0,0 +1,36 @@ +import { createHash } from "node:crypto"; + +// v1 has no question-create endpoint. Resolve only these pinned, embedded exports +// so the setup fixture can call the native service without copying or patching it. +export function v1QuestionBridge(binary: Buffer) { + function embedded(marker: string) { + const offset = binary.indexOf(marker); + if (offset < 0) throw new Error(`Pinned v1 question setup missing ${marker}`); + const start = binary.lastIndexOf(0, offset); + const end = binary.indexOf(0, offset); + const pathStart = binary.lastIndexOf(0, start - 1); + const path = binary.subarray(pathStart + 1, start).toString(); + const source = binary.subarray(start + 1, end).toString(); + if (!/^\/\$bunfs\/root\/chunk-[\w]+\.js$/.test(path) || !source.startsWith("// @bun")) { + throw new Error("Pinned v1 embedded module layout changed"); + } + return { path, source, sha256: createHash("sha256").update(source).digest("hex") }; + } + const runtime = embedded(" as AppRuntime,"); + const store = embedded(" as InstanceStore}"); + const question = embedded("Question.ask"); + const namespace = /Question:\(\)=>([\w$]+)/.exec(question.source)?.[1]; + const exports = question.source.slice(question.source.lastIndexOf("export{")); + const questionExport = namespace && new RegExp(`(?:\\{|,)${namespace} as ([\\w$]+)[,}]`).exec(exports)?.[1]; + if (!questionExport || !question.source.includes('Service()("@opencode/Question")')) { + throw new Error("Pinned v1 Question service export changed"); + } + return { + source: [ + `export { AppRuntime } from ${JSON.stringify(runtime.path)};`, + `export { InstanceStore } from ${JSON.stringify(store.path)};`, + `export { ${questionExport} as Question } from ${JSON.stringify(question.path)};`, + ].join("\n"), + modules: [runtime, store, question].map(({ path, sha256 }) => ({ path, sha256 })), + }; +} diff --git a/test/e2e/v1-question.unit.ts b/test/e2e/v1-question.unit.ts new file mode 100644 index 0000000..a8cb8f4 --- /dev/null +++ b/test/e2e/v1-question.unit.ts @@ -0,0 +1,48 @@ +import { expect, it } from "bun:test"; +import { v1QuestionBridge } from "./v1-question"; + +function binary( + question = 'var q={};map(q,{Question:()=>q});class Service extends Tag.Service()("@opencode/Question"){};var ask="Question.ask";export{q as Q};', +) { + return Buffer.from( + [ + "native prefix", + "/$bunfs/root/chunk-runtime.js", + "// @bun\nexport{r as AppRuntime,l as AppLayer};", + "/$bunfs/root/chunk-store.js", + "// @bun\nexport{s as InstanceStore};", + "/$bunfs/root/chunk-question.js", + `// @bun\n${question}`, + "native suffix", + ].join("\0"), + ); +} + +it("resolves native v1 question setup exports and records their identities without copying code", () => { + const bridge = v1QuestionBridge(binary()); + expect(bridge.source).toBe( + [ + 'export { AppRuntime } from "/$bunfs/root/chunk-runtime.js";', + 'export { InstanceStore } from "/$bunfs/root/chunk-store.js";', + 'export { Q as Question } from "/$bunfs/root/chunk-question.js";', + ].join("\n"), + ); + expect(bridge.modules).toHaveLength(3); + expect(bridge.modules.every(({ sha256 }) => /^[a-f0-9]{64}$/.test(sha256))).toBe(true); + expect(bridge.source).not.toContain("class Service"); +}); + +it("fails closed when pinned embedded layout or service exports change", () => { + expect(() => v1QuestionBridge(Buffer.from("not a native host"))).toThrow("missing"); + expect(() => + v1QuestionBridge(Buffer.from(binary().toString().replace("/$bunfs/root/chunk-question.js", "/untrusted.js"))), + ).toThrow("layout changed"); + expect(() => + v1QuestionBridge(binary('var q={};map(q,{Question:()=>q});var ask="Question.ask";export{q as Q};')), + ).toThrow("service export changed"); + expect(() => + v1QuestionBridge( + binary('class Service extends Tag.Service()("@opencode/Question"){};var ask="Question.ask";export{q as Q};'), + ), + ).toThrow("service export changed"); +}); diff --git a/test/editor.test.ts b/test/editor.test.ts new file mode 100644 index 0000000..1083b21 --- /dev/null +++ b/test/editor.test.ts @@ -0,0 +1,118 @@ +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 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); + }); + + 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); + 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", () => { + let renders = 0; + selectVisualCharacterRange({ cursorOffset: 1, requestRender: () => renders++ }, 0); + expect(renders).toBe(0); + }); +}); diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..53d418c --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,17 @@ +import type { PromptAccess } from "../src/vim"; + +export const mockPrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "hello world\nsecond line\nthird line", +}; + +export const emptyPrompt: PromptAccess = { + getLine: () => "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "", +}; diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..73295e2 --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,662 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, finishOneShotIfComplete, handleInsertKey, handleNormalKey, type VimState } from "../src/vim"; +import { mockPrompt } from "./fixtures"; +import { ev } from "./support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── Ctrl+O one-shot normal mode ─────────────────────────── + +describe("Ctrl+O one-shot normal mode", () => { + function enterOneShot() { + state.mode = "insert"; + handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); + } + + it("w auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("3w auto-returns to insert after count is consumed", () => { + enterOneShot(); + handleNormalKey(state, "3", ev("3"), mockPrompt); + expect(state.oneShotNormal).toBe(true); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("dw auto-returns to insert after operator+motion", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("dd auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("r{char} auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "r", ev("r"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "a", ev("a"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("gg auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "g", ev("g"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "g", ev("g"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("cw enters insert directly without double mode switch", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); + expect(modeActions).toHaveLength(1); + }); + + it("u auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "u", ev("u"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("p auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "p", ev("p"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it(": auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, ":", ev(":"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("e auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "e", ev("e"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("escape during one-shot returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("v during one-shot cancels one-shot and enters visual", () => { + enterOneShot(); + handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(state.mode).toBe("visual"); + expect(state.oneShotNormal).toBe(false); + }); + + it("sequential Ctrl+O usage works (flag resets cleanly)", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + // Second round + enterOneShot(); + expect(state.oneShotNormal).toBe(true); + const r2 = handleNormalKey(state, "b", ev("b"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("cc enters insert directly without double mode switch", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); + expect(modeActions).toHaveLength(1); + }); + + it("de auto-returns to insert (deleteRange path)", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "e", ev("e"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r2.actions.some((a) => a.type === "deleteRange")).toBe(true); + }); + + it("does not auto-return when not in one-shot mode", () => { + state.mode = "normal"; + state.oneShotNormal = false; + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("normal"); + }); + + it("finishOneShotIfComplete does not double-append insert when the result already enters insert", () => { + state.oneShotNormal = true; + const result = { consume: true, actions: [{ type: "mode", mode: "insert" } as const] }; + finishOneShotIfComplete(state, result); + expect(state.oneShotNormal).toBe(false); + expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); + }); + + it("Ctrl+O one-shot stays in normal mode while an operator is pending", () => { + state.oneShotNormal = true; + const result = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, result); + expect(state.mode).toBe("normal"); + }); +}); + +describe("version sync", () => { + it("VERSION matches package.json", async () => { + const pkg = await import("../package.json"); + const { VERSION } = await import("../src/version"); + expect(VERSION).toBe(pkg.version); + }); +}); + +// ── plugin init sanity check ────────────────────────────── + +describe("plugin init", () => { + it("tui() does not throw with a minimal mock API", async () => { + const plugin = (await import("../src/index")).default; + expect(plugin.id).toBe("vimcode"); + + // Minimal mock matching what OpenCode passes to tui(). + // Intentionally sparse — some fields are undefined or stubs, + // which is exactly the hostile environment we need to survive. + const dispatchCommand = () => ({ ok: false }); + const api = { + renderer: undefined, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { intercept: () => {}, dispatchCommand }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: { get: async () => undefined }, // empty object — the scenario that crashed v0.7.0 + }; + + // Should not throw with a sparse mock API. + // biome-ignore lint/suspicious/noExplicitAny: mock API doesn't match full plugin types + await plugin.tui(api as any, undefined, undefined as any); + }); +}); + +// ── undo snapshot integration ───────────────────────────── + +describe("undo snapshot — deleteRange + u", () => { + // Exercises the full pipeline: key event → handler → applyActions → editor state. + // The contract: u after dG restores the full buffer in one step via + // editBuffer.setText, not the host's per-line input.undo. + + function createMockEditor(text: string, cursor: number) { + let editorText = text; + let editorCursor = cursor; + const calls: { method: string; args: unknown[] }[] = []; + const editor = { + get plainText() { + return editorText; + }, + get cursorOffset() { + return editorCursor; + }, + set cursorOffset(v: number) { + editorCursor = v; + }, + visualCursor: { logicalRow: 1 }, + cursorStyle: { style: "block" as const, blinking: true }, + insertText: () => {}, + setSelectionInclusive: () => {}, + editorView: { resetSelection: () => {} }, + editBuffer: { + deleteRange: (sl: number, sc: number, el: number, ec: number) => { + calls.push({ method: "deleteRange", args: [sl, sc, el, ec] }); + editorText = editorText.substring(0, cursor); + }, + setText: (t: string) => { + calls.push({ method: "setText", args: [t] }); + editorText = t; + }, + }, + }; + return { editor, calls, getText: () => editorText, getCursor: () => editorCursor }; + } + + async function setup(text: string, cursor: number) { + const plugin = (await import("../src/index")).default; + const { editor, calls, getText, getCursor } = createMockEditor(text, cursor); + const dispatched: string[] = []; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const api = { + renderer: { currentFocusedEditor: editor, currentFocusedRenderable: editor }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: (cmd: string) => { + dispatched.push(cmd); + return { ok: false }; + }, + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, undefined, undefined as any); + + const press = (name: string, opts: Record = {}) => { + handler?.({ event: { name, eventType: "press", ...opts }, consume: () => {} }); + }; + + // Enter normal mode + press("escape"); + + return { press, calls, dispatched, getText, getCursor }; + } + + it("u after dG restores the full buffer via editBuffer.setText", async () => { + const original = "hello world\nsecond line\nthird line"; + const { press, calls, dispatched, getCursor } = await setup(original, 12); + + press("d"); + press("g", { shift: true }); + expect(calls.some((c) => c.method === "deleteRange")).toBe(true); + + calls.length = 0; + press("u"); + + expect(calls).toContainEqual({ method: "setText", args: [original] }); + expect(getCursor()).toBe(12); + expect(dispatched).not.toContain("input.undo"); + }); + + it("u after dG then a motion falls back to host input.undo", async () => { + const { press, calls, dispatched } = await setup("hello world\nsecond line\nthird line", 12); + + press("d"); + press("g", { shift: true }); + expect(calls.some((c) => c.method === "deleteRange")).toBe(true); + + // h dispatches input.move.left (a cmd action), invalidating the snapshot + press("h"); + + calls.length = 0; + dispatched.length = 0; + press("u"); + + expect(calls.every((c) => c.method !== "setText")).toBe(true); + // input.undo is dispatched via setTimeout + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); + + it("u after 3dw restores the full buffer via editBuffer.setText", async () => { + const original = "hello world second line third line"; + const { press, calls, dispatched, getCursor } = await setup(original, 0); + + press("3"); + press("d"); + press("w"); + + calls.length = 0; + press("u"); + + expect(calls).toContainEqual({ method: "setText", args: [original] }); + expect(getCursor()).toBe(0); + expect(dispatched).not.toContain("input.undo"); + }); + + it("u after 3dw then dd unwinds the snapshot stack one step per press", async () => { + const original = "hello world second line third line"; + const { press, calls, dispatched } = await setup(original, 0); + + // Two stacked undoable changes → two snapshots on the stack. + press("3"); + press("d"); + press("w"); + press("d"); + press("d"); + + // First u pops the dd snapshot, second pops the 3dw snapshot — each a + // local restore via setText, never the host's input.undo. + calls.length = 0; + dispatched.length = 0; + press("u"); + expect(calls.some((c) => c.method === "setText")).toBe(true); + expect(dispatched).not.toContain("input.undo"); + + calls.length = 0; + press("u"); + expect(calls.some((c) => c.method === "setText")).toBe(true); + expect(dispatched).not.toContain("input.undo"); + + // Stack is now empty — a third u falls through to host undo. + calls.length = 0; + press("u"); + expect(calls.every((c) => c.method !== "setText")).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); + + it("u after 3dw then an insert-mode edit falls back to host input.undo", async () => { + const { press, calls, dispatched } = await setup("hello world second line third line", 0); + + press("3"); + press("d"); + press("w"); + + // Enter insert and modify the buffer. The insert edit emits an + // insertText action, which clears the vim snapshot stack. + press("i"); + press("tab"); + press("escape"); + + calls.length = 0; + dispatched.length = 0; + press("u"); + + expect(calls.every((c) => c.method !== "setText")).toBe(true); + // input.undo is dispatched via setTimeout + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); +}); + +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 }, + editBuffer: { getTextRange: () => "l" }, + editorView: { + resetSelection: () => {}, + 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", () => { + // #63: in normal mode the intercept consumed arrow keys, so OpenCode never + // saw them and couldn't exit the subagent view. This drives the real + // pipeline (plugin.tui → key intercept) and asserts consume() is not called + // for arrows, while a vim motion still is. + async function setup() { + const plugin = (await import("../src/index")).default; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const api = { + renderer: { currentFocusedEditor: undefined }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: () => ({ ok: false }), + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, { updateCheck: false } as any, undefined as any); + + // Returns whether the intercept consumed the key (i.e. called consume()). + const press = (name: string, opts: Record = {}) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press", ...opts }, + consume: () => { + consumed = true; + }, + }); + return consumed; + }; + + press("escape"); // leave insert, enter normal mode + return { press }; + } + + for (const arrow of ["up", "down", "left", "right"] as const) { + it(`${arrow} in normal mode is not consumed, so the host handles it`, async () => { + const { press } = await setup(); + expect(press(arrow)).toBe(false); + }); + } + + it("a vim motion (j) is still consumed, proving the harness detects consumption", async () => { + const { press } = await setup(); + expect(press("j")).toBe(true); + }); +}); + +// ── prompt overlay tracking (question.rejected leak) ────── + +describe("prompt overlay tracking", () => { + // The plugin tracks pending permission/question prompts via events so it can + // pass keys through while an overlay owns the keyboard. Every "asked" must be + // balanced by a terminal event, otherwise hasActivePrompts() stays true and + // the plugin is stuck passing all keys (including Escape) to the host. + async function setup() { + const plugin = (await import("../src/index")).default; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + const events = new Map void>(); + const sessions: Record = { root: {}, child: { parentID: "root" } }; + + const api = { + renderer: { currentFocusedEditor: undefined }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: () => ({ ok: false }), + }, + route: { current: { name: "session", params: { sessionID: "root" } } }, + state: { + session: { + get: (id: string) => sessions[id], + question: () => [], + permission: () => [], + }, + }, + event: { + on: (name: string, h: (e: unknown) => void) => { + events.set(name, h); + return () => events.delete(name); + }, + }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, { updateCheck: false } as any, undefined as any); + + const press = (name: string) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press" }, + consume: () => { + consumed = true; + }, + }); + return consumed; + }; + + const emit = (name: string, properties: Record) => events.get(name)?.({ properties }); + + press("escape"); // leave insert, enter normal mode + return { press, emit }; + } + + it("keys pass through while a question is pending, then resume after question.rejected", async () => { + const { press, emit } = await setup(); + + emit("question.asked", { id: "q1", sessionID: "root" }); + expect(press("h")).toBe(false); + + emit("question.rejected", { requestID: "q1", sessionID: "root" }); + expect(press("h")).toBe(true); + }); + + it("question.replied also resumes key consumption", async () => { + const { press, emit } = await setup(); + + emit("question.asked", { id: "q1", sessionID: "root" }); + expect(press("h")).toBe(false); + + emit("question.replied", { requestID: "q1", sessionID: "root" }); + expect(press("h")).toBe(true); + }); + + it("permission.replied resumes key consumption", async () => { + const { press, emit } = await setup(); + + emit("permission.asked", { id: "p1", sessionID: "root" }); + expect(press("h")).toBe(false); + + emit("permission.replied", { requestID: "p1", sessionID: "root" }); + expect(press("h")).toBe(true); + }); + + it("a prompt on a child session is tracked against its root", async () => { + const { press, emit } = await setup(); + + emit("question.asked", { id: "q1", sessionID: "child" }); + expect(press("h")).toBe(false); + + emit("question.rejected", { requestID: "q1", sessionID: "child" }); + expect(press("h")).toBe(true); + }); +}); diff --git a/test/question-answer-support.ts b/test/question-answer-support.ts new file mode 100644 index 0000000..fb00166 --- /dev/null +++ b/test/question-answer-support.ts @@ -0,0 +1,471 @@ +import { mock } from "bun:test"; +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { registerEnabledFields, registerLeader } from "@opentui/keymap/addons"; +import { createTestKeymap, type TestKeyModifierOptions, TestKeymapEvent } from "@opentui/keymap/testing"; +import plugin from "../src/index"; +import type { V2Context } from "../src/v2"; + +// Exercise register actions without reading or writing the desktop clipboard. +export const clipboardWrites: string[] = []; +mock.module("../src/clipboard", () => ({ + writeClipboard(text: string) { + clipboardWrites.push(text); + }, +})); + +type PasteMetadata = Parameters[1]; +type PasteInputEvent = { + bytes: Uint8Array; + metadata?: PasteMetadata; + defaultPrevented: boolean; + propagationStopped: boolean; + preventDefault(): void; + stopPropagation(): void; +}; + +export function answerEditor(text = "hello world", status = "ANSWER", clearSelectionOnCursorMove = false) { + let selection: { start: number; end: number } | undefined; + let cursor = text.length; + const editor = { + traits: { status }, + plainText: text, + get cursorOffset() { + return cursor; + }, + set cursorOffset(offset: number) { + if (clearSelectionOnCursorMove) selection = undefined; + cursor = offset; + }, + cursorStyle: { style: "block", blinking: true }, + get visualCursor() { + return { logicalRow: editor.plainText.slice(0, editor.cursorOffset).split("\n").length - 1 }; + }, + insertText(value: string) { + editor.plainText = + editor.plainText.slice(0, editor.cursorOffset) + value + editor.plainText.slice(editor.cursorOffset); + editor.cursorOffset += value.length; + }, + setSelectionInclusive(start: number, end: number) { + selection = { start: Math.min(start, end), end: Math.max(start, end) + 1 }; + }, + editorView: { + resetSelection() { + selection = undefined; + }, + setSelection(start: number, end: number) { + selection = { start, end }; + }, + getSelectedText() { + return selection ? editor.plainText.slice(selection.start, selection.end) : ""; + }, + }, + editBuffer: { + getTextRange(start: number, end: number) { + return editor.plainText.slice(start, end); + }, + setText(value: string) { + editor.plainText = value; + }, + deleteRange(sl: number, sc: number, el: number, ec: number) { + const lines = editor.plainText.split("\n"); + const start = lines.slice(0, sl).reduce((n, line) => n + line.length + 1, 0) + sc; + const end = lines.slice(0, el).reduce((n, line) => n + line.length + 1, 0) + ec; + editor.plainText = editor.plainText.slice(0, start) + editor.plainText.slice(end); + editor.cursorOffset = start; + }, + }, + command(command: string) { + const offset = editor.cursorOffset; + if (command === "input.move.left") editor.cursorOffset = Math.max(0, offset - 1); + if (command === "input.move.right") editor.cursorOffset = Math.min(editor.plainText.length, offset + 1); + if (command === "input.select.right" || command === "input.select.left") { + editor.cursorOffset = Math.max( + 0, + Math.min(editor.plainText.length, offset + (command.endsWith("right") ? 1 : -1)), + ); + selection = { start: Math.min(offset, editor.cursorOffset), end: Math.max(offset, editor.cursorOffset) }; + } + if (command === "input.delete") + editor.plainText = editor.plainText.slice(0, offset) + editor.plainText.slice(offset + 1); + if (command === "input.backspace" && selection) { + editor.plainText = editor.plainText.slice(0, selection.start) + editor.plainText.slice(selection.end); + editor.cursorOffset = selection.start; + selection = undefined; + } + }, + get selection() { + return selection; + }, + }; + return editor; +} + +export async function questionHost(version: "v1" | "v2", options: Record = {}) { + const { keymap, host, diagnostics, cleanup } = createTestKeymap({ defaultKeys: true }); + registerEnabledFields(keymap); + registerLeader(keymap, { trigger: "space" }); + const disposals: Array<() => void> = [cleanup]; + const rawListeners: Array<(event: TestKeymapEvent) => void> = []; + const pasteListeners: Array<(event: PasteInputEvent) => void> = []; + function prependInput(event: "keypress", listener: (event: TestKeymapEvent) => void): void; + function prependInput(event: "paste", listener: (event: PasteInputEvent) => void): void; + function prependInput( + event: "keypress" | "paste", + listener: ((event: TestKeymapEvent) => void) | ((event: PasteInputEvent) => void), + ) { + if (event === "keypress") rawListeners.unshift(listener as (event: TestKeymapEvent) => void); + else pasteListeners.unshift(listener as (event: PasteInputEvent) => void); + } + function removeInput(event: "keypress", listener: (event: TestKeymapEvent) => void): void; + function removeInput(event: "paste", listener: (event: PasteInputEvent) => void): void; + function removeInput( + event: "keypress" | "paste", + listener: ((event: TestKeymapEvent) => void) | ((event: PasteInputEvent) => void), + ) { + if (event === "keypress") { + const index = rawListeners.indexOf(listener as (event: TestKeymapEvent) => void); + if (index >= 0) rawListeners.splice(index, 1); + } else { + const index = pasteListeners.indexOf(listener as (event: PasteInputEvent) => void); + if (index >= 0) pasteListeners.splice(index, 1); + } + } + const commands: Array<{ id: string; run(): void | Promise }> = []; + const dispatched: string[] = []; + const hostActions: string[] = []; + const committedAnswers: string[] = []; + const navigatedAnswers: string[] = []; + const paletteSubmissions: string[] = []; + const pasteReplays: Array<{ text: string; metadata?: PasteMetadata }> = []; + const toasts: string[] = []; + const events = new Map void>(); + const main = answerEditor("main prompt", "PROMPT"); + const renderer = { + currentFocusedEditor: main as ReturnType | undefined, + currentFocusedRenderable: main as object | undefined, + keyInput: { + prependListener: prependInput, + off: removeInput, + processParsedKey(event: TestKeyModifierOptions & { name: string }) { + press(event.name, event); + return true; + }, + processPaste(bytes: Uint8Array, metadata?: PasteMetadata) { + const text = new TextDecoder().decode(bytes); + pasteReplays.push({ text, metadata }); + paste(text, metadata); + }, + }, + }; + let mode = "base"; + let sessionID = "root"; + let question = false; + let permission = false; + let dialog = false; + let paletteReturn: { editor: ReturnType | undefined; mode: string } | undefined; + keymap.registerLayer({ + commands: [ + { + name: "command.palette.show", + run() { + paletteReturn = { editor: renderer.currentFocusedEditor, mode }; + function openPalette() { + dialog = true; + mode = "modal"; + const input = answerEditor("", "PALETTE"); + if (options.delayedPalette) queueMicrotask(() => focus(input)); + else focus(input); + hostActions.push("palette"); + } + if (options.delayedPalette) queueMicrotask(openPalette); + else openPalette(); + }, + }, + ], + }); + keymap.registerLayer({ + enabled: () => mode === "modal" && paletteReturn !== undefined, + commands: [ + { + name: "palette.input", + run() { + hostActions.push("palette:h"); + }, + }, + { + name: "palette.submit", + run() { + hostActions.push("palette:submit"); + paletteSubmissions.push(renderer.currentFocusedEditor?.plainText ?? ""); + }, + }, + { + name: "palette.close", + run() { + if (!paletteReturn) throw new Error("Palette not open"); + dialog = false; + mode = paletteReturn.mode; + focus(paletteReturn.editor); + paletteReturn = undefined; + }, + }, + ], + bindings: [ + { key: "h", cmd: "palette.input" }, + { key: "return", cmd: "palette.submit" }, + { key: "escape", cmd: "palette.close" }, + ], + }); + const settings = { disabled: options.disabled === true, lastUpdateCheck: "" }; + const inputTarget = host.rootTarget.append(host.createTarget("editor")); + host.focus(inputTarget); + const isEditing = () => mode === (version === "v1" ? "question" : "form") && !!renderer.currentFocusedEditor; + keymap.registerLayer({ + enabled: isEditing, + commands: [ + { + name: "answer.commit", + run() { + hostActions.push("commit"); + committedAnswers.push(renderer.currentFocusedEditor?.plainText ?? ""); + focus(undefined); + }, + }, + { + name: "answer.cancel", + run() { + hostActions.push("cancel"); + focus(undefined); + }, + }, + { + name: "answer.tab", + run() { + hostActions.push("tab"); + navigatedAnswers.push(renderer.currentFocusedEditor?.plainText ?? ""); + focus(undefined); + }, + }, + ], + bindings: [ + { key: "return", cmd: "answer.commit" }, + { key: "ctrl+return", cmd: "answer.commit" }, + { key: "escape", cmd: "answer.cancel" }, + { key: "tab", cmd: "answer.tab" }, + ], + }); + keymap.registerLayer({ + commands: [ + "input.move.left", + "input.move.right", + "input.move.down", + "input.move.up", + "input.select.left", + "input.select.right", + "input.delete", + "input.backspace", + "input.undo", + ].map((name) => ({ + name, + run() { + renderer.currentFocusedEditor?.command(name); + }, + })), + }); + const dispatch = (command: string) => { + dispatched.push(command); + return keymap.dispatchCommand(command); + }; + const api = { + renderer, + keymap: { + intercept: keymap.intercept.bind(keymap), + dispatchCommand: dispatch, + mode: options.noHostMode ? undefined : { current: () => mode }, + registerLayer(input: { commands: Array<{ name: string; run(): void | Promise }> }) { + commands.push(...input.commands.map((command) => ({ id: command.name, run: command.run }))); + }, + }, + ui: { + toast(input: { message: string }) { + toasts.push(input.message); + }, + dialog: { + get open() { + return dialog; + }, + }, + }, + route: { + get current() { + return { name: "session", params: { sessionID } }; + }, + }, + state: { + session: { + get(id: string) { + return id === "child" ? { parentID: "root" } : {}; + }, + question: () => (question ? [{}] : []), + permission: () => (permission ? [{}] : []), + }, + }, + kv: { + get: async () => settings.disabled, + set: async (_key: string, value: unknown) => { + settings.disabled = value === true; + }, + }, + event: { + on(name: string, handler: (event: { properties: { sessionID: string } }) => void) { + events.set(name, handler); + return () => { + events.delete(name); + }; + }, + }, + lifecycle: { + onDispose(dispose: () => void) { + disposals.push(dispose); + }, + }, + tuiConfig: { keybinds: { get: () => [{ key: "space" }] } }, + }; + if (version === "v1") await plugin.tui(api as unknown as TuiPluginApi, { updateCheck: false, ...options }); + else { + const context: V2Context = { + options: { updateCheck: false, experimentalV2Leader: "space", ...options }, + renderer, + keymap: { + dispatch, + mode: { current: () => mode }, + layer(input) { + commands.push(...input().commands); + }, + }, + storage: { + store() { + return [ + settings as unknown as T, + async (update: (value: T) => void) => update(settings as unknown as T), + ] as const; + }, + }, + data: { + on: () => () => {}, + session: { + root: (id) => (id === "child" ? "root" : id), + family: () => ["root", "child"], + form: { list: () => (question ? [{}] : []) }, + permission: { list: () => (permission ? [{}] : []) }, + }, + }, + ui: { + toast: { show: api.ui.toast }, + router: { current: () => ({ type: "session", sessionID }) }, + slot({ render }) { + render(); + return () => {}; + }, + }, + }; + disposals.push(await plugin.setup(context)); + } + function focus(editor: ReturnType | undefined) { + renderer.currentFocusedEditor = editor; + renderer.currentFocusedRenderable = editor; + } + function open(editor = answerEditor(), child = false) { + question = true; + mode = version === "v1" ? "question" : "form"; + sessionID = child ? "child" : "root"; + focus(editor); + return editor; + } + function close() { + question = false; + mode = "base"; + sessionID = "root"; + focus(main); + } + function press(name: string, flags: TestKeyModifierOptions = {}) { + let event = new TestKeymapEvent(name, flags); + for (const listener of [...rawListeners]) { + listener(event); + if (event.propagationStopped) break; + } + if (!event.propagationStopped) event = host.press(name, flags); + if ( + !event.propagationStopped && + !event.defaultPrevented && + name.length === 1 && + !flags.ctrl && + !flags.meta && + !flags.super + ) { + renderer.currentFocusedEditor?.insertText(name); + } + return event; + } + function paste(text: string, metadata?: PasteMetadata) { + const event: PasteInputEvent = { + bytes: new TextEncoder().encode(text), + metadata, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + }; + for (const listener of [...pasteListeners]) { + listener(event); + if (event.propagationStopped) break; + } + if (!event.propagationStopped && !event.defaultPrevented) renderer.currentFocusedEditor?.insertText(text); + return event; + } + function dispose() { + for (const fn of disposals.splice(0).reverse()) fn(); + } + return { + main, + renderer, + dispatched, + hostActions, + committedAnswers, + navigatedAnswers, + paletteSubmissions, + pasteReplays, + toasts, + diagnostics, + open, + close, + press, + paste, + focus, + dispose, + setMode(value: string) { + mode = value; + }, + setQuestion(value: boolean) { + question = value; + }, + setDialog(value: boolean) { + dialog = value; + mode = value ? "modal" : "base"; + }, + setPermission(value: boolean) { + permission = value; + mode = value ? "permission" : "base"; + }, + emit(name: string, sessionID = "root") { + events.get(name)?.({ properties: { sessionID } }); + }, + async toggle() { + await commands.find((command) => command.id === "vimcode.vim")?.run(); + }, + }; +} diff --git a/test/question-answer.test.ts b/test/question-answer.test.ts new file mode 100644 index 0000000..b3f5760 --- /dev/null +++ b/test/question-answer.test.ts @@ -0,0 +1,687 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { answerEditor, clipboardWrites, questionHost } from "./question-answer-support"; + +const disposals: Array<() => void> = []; +const flush = () => Bun.sleep(5); +async function waitFor(check: () => boolean) { + for (let attempt = 0; attempt < 100; attempt++) { + if (check()) return; + await Bun.sleep(5); + } + throw new Error("Timed out waiting for palette key delivery"); +} +afterEach(async () => { + await flush(); + for (const dispose of disposals.splice(0)) dispose(); + clipboardWrites.length = 0; +}); + +for (const version of ["v1", "v2"] as const) { + async function setup(options: Record = {}) { + const mock = await questionHost(version, options); + disposals.push(mock.dispose); + return mock; + } + describe(`#78 — ${version} question answer editing`, () => { + for (const backwards of [false, true]) { + it(`retains a ${backwards ? "backward" : "forward"} visual find selection when cursor movement clears selection`, async () => { + const mock = await setup(); + const answer = mock.open(answerEditor("xaxbxcx", "ANSWER", true)); + mock.press("escape"); + if (!backwards) answer.cursorOffset = 0; + mock.press("v"); + mock.press("f", { shift: backwards }); + mock.press("x"); + expect(answer.cursorOffset).toBe(backwards ? 4 : 2); + expect(answer.selection).toEqual(backwards ? { start: 4, end: 7 } : { start: 0, end: 3 }); + mock.press("x"); + await flush(); + expect(answer.plainText).toBe(backwards ? "xaxb" : "bxcx"); + }); + } + it("keeps character-find history independent between main and answer editors", async () => { + const mock = await setup({ startMode: "normal" }); + mock.main.editBuffer.setText("xaxbxcx"); + mock.main.cursorOffset = 0; + mock.press("f"); + mock.press("x"); + expect(mock.main.cursorOffset).toBe(2); + const answer = mock.open(answerEditor("xaxbxcx")); + mock.press("escape"); + const initial = answer.cursorOffset; + mock.press(";"); + expect(answer.cursorOffset).toBe(initial); + mock.press("f", { shift: true }); + mock.press("x"); + expect(answer.cursorOffset).toBe(4); + mock.press(";"); + expect(answer.cursorOffset).toBe(2); + mock.close(); + mock.press(";"); + expect(mock.main.cursorOffset).toBe(4); + const other = mock.open(answerEditor("xaxbxcx")); + mock.press("escape"); + mock.press(";"); + expect(other.cursorOffset).toBe(6); + expect(mock.diagnostics.errors).toEqual([]); + }); + it("finds a printable leader as the target instead of starting a leader sequence", async () => { + const mock = await setup({ startMode: "normal" }); + mock.main.editBuffer.setText("a b c"); + mock.main.cursorOffset = 0; + mock.press("f"); + expect(mock.press("space").propagationStopped).toBe(true); + expect(mock.main.cursorOffset).toBe(1); + mock.press(";"); + expect(mock.main.cursorOffset).toBe(3); + expect(mock.main.plainText).toBe("a b c"); + }); + it("undoes an answer's character-find deletion without modifying the main prompt", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(answerEditor("xaxbxcx")); + mock.press("escape"); + mock.press("d"); + mock.press("f", { shift: true }); + mock.press("x"); + expect(answer.plainText).toBe("xaxbx"); + mock.press("u"); + expect(answer.plainText).toBe("xaxbxcx"); + expect(answer.cursorOffset).toBe(6); + expect(mock.main.plainText).toBe("main prompt"); + }); + for (const mainMode of ["insert", "normal", "visual"] as const) { + it(`starts the answer in insert, restores main ${mainMode} on return`, async () => { + const mock = await setup({ startMode: mainMode === "insert" ? "insert" : "normal" }); + if (mainMode === "visual") mock.press("v"); + const answer = mock.open(); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + mock.press("z"); + expect(answer.plainText).toBe("hello worldz"); + expect(mock.press("escape").propagationStopped).toBe(true); + expect(mock.hostActions).toEqual([]); + mock.press("h"); + await flush(); + expect(answer.cursorOffset).toBe(10); + mock.close(); + mock.dispatched.length = 0; + mock.press(mainMode === "visual" ? "l" : "h"); + await flush(); + if (mainMode === "insert") expect(mock.main.plainText).toBe("main prompth"); + else expect(mock.dispatched).toEqual([mainMode === "visual" ? "input.select.right" : "input.move.left"]); + }); + } + it("consumes first Escape, uses focused edit commands, and lets normal Escape cancel", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(); + expect(mock.press("escape").propagationStopped).toBe(true); + await flush(); + expect(mock.hostActions).toEqual([]); + expect(answer.cursorOffset).toBe(10); + mock.press("h"); + mock.press("x"); + await flush(); + expect(answer.plainText).toBe("hello word"); + expect(mock.main.plainText).toBe("main prompt"); + mock.press("escape"); + expect(mock.hostActions).toEqual(["cancel"]); + expect(mock.diagnostics.errors).toEqual([]); + }); + for (const mode of ["insert", "normal", "visual"] as const) { + it(`preserves Enter, Ctrl+Enter and Tab host behavior in answer ${mode}`, async () => { + const mock = await setup(); + for (const [key, flags] of [ + ["return", {}], + ["return", { ctrl: true }], + ["tab", {}], + ] as const) { + mock.open(); + if (mode !== "insert") mock.press("escape"); + if (mode === "visual") mock.press("v"); + mock.press(key, flags); + } + await flush(); + expect(mock.hostActions).toEqual(["commit", "commit", "tab"]); + expect(mock.dispatched).not.toContain("input.submit"); + expect(mock.dispatched).not.toContain("input.newline"); + expect(mock.dispatched.filter((command) => command.startsWith("prompt.autocomplete"))).toEqual([]); + }); + } + for (const [label, key, flags] of [ + ["Enter", "return", {}], + ["Ctrl+Enter", "return", { ctrl: true }], + ["Tab", "tab", {}], + ] as const) { + it(`applies a rapid delete before native ${label} changes focus`, async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("x"); + mock.press(key, flags); + await flush(); + expect(key === "tab" ? mock.navigatedAnswers : mock.committedAnswers).toEqual(["hello worl"]); + expect(answer.plainText).toBe("hello worl"); + }); + } + it("applies queued motions before the next insert key", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("h"); + mock.press("i"); + mock.press("!"); + await flush(); + expect(answer.plainText).toBe("hello wor!ld"); + expect(answer.cursorOffset).toBe(10); + }); + it("applies queued append movement before native paste", async () => { + const mock = await setup(); + const answer = mock.open(); + const metadata = { kind: "text" as const, mimeType: "text/plain" }; + mock.press("escape"); + mock.press("a"); + const paste = mock.paste("X", metadata); + expect(paste.defaultPrevented).toBe(false); + expect(paste.propagationStopped).toBe(false); + expect(answer.plainText).toBe("hello worldX"); + expect(answer.cursorOffset).toBe(12); + await flush(); + expect(answer.plainText).toBe("hello worldX"); + expect(answer.cursorOffset).toBe(12); + expect(mock.dispatched).toEqual(["input.move.right"]); + expect(mock.pasteReplays).toEqual([]); + }); + it("does not drain a previous answer's actions when paste goes to the main prompt", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("a"); + mock.close(); + mock.paste("X"); + await flush(); + expect(mock.dispatched).not.toContain("input.move.right"); + expect(answer.plainText).toBe("hello world"); + expect(answer.cursorOffset).toBe(10); + expect(mock.main.plainText).toBe("main promptX"); + }); + it("restores a line cursor when Vim is disabled from normal mode", async () => { + const mock = await setup({ startMode: "normal" }); + await Bun.sleep(120); + expect(mock.main.cursorStyle.style).toBe("block"); + await mock.toggle(); + await Bun.sleep(120); + expect(mock.main.cursorStyle.style).toBe("line"); + await mock.toggle(); + const answer = mock.open(); + mock.press("escape"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("block"); + await mock.toggle(); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + }); + it("restores the answer's line cursor after disabling through the palette", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("block"); + mock.press(":"); + await flush(); + await mock.toggle(); + mock.press("escape"); + await Bun.sleep(120); + expect(mock.renderer.currentFocusedEditor).toBe(answer); + expect(answer.cursorStyle.style).toBe("line"); + }); + it("deletes a text object and undoes without borrowing main snapshots", async () => { + const mock = await setup({ startMode: "normal" }); + mock.main.cursorOffset = 0; + mock.press("d"); + mock.press("i"); + mock.press("w"); + expect(mock.main.plainText).toBe(" prompt"); + const answer = mock.open(); + mock.press("escape"); + mock.press("g"); + mock.press("g"); + mock.press("d"); + mock.press("i"); + mock.press("w"); + expect(answer.plainText).toBe(" world"); + mock.close(); + mock.press("u"); + expect(mock.main.plainText).toBe("main prompt"); + expect(answer.plainText).toBe(" world"); + mock.open(answer); + mock.press("u"); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe("main prompt"); + }); + it("cannot borrow a main snapshot when the answer has no undo snapshots", async () => { + const mock = await setup({ startMode: "normal" }); + mock.main.cursorOffset = 0; + mock.press("d"); + mock.press("i"); + mock.press("w"); + const answer = mock.open(); + mock.press("escape"); + mock.press("u"); + await flush(); + expect(mock.dispatched).toContain("input.undo"); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe(" prompt"); + mock.close(); + mock.press("u"); + expect(mock.main.plainText).toBe("main prompt"); + }); + it("keeps answer and main yank registers separate and pastes locally", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("y"); + mock.press("y"); + const answer = mock.open(); + mock.press("escape"); + mock.press("g"); + mock.press("g"); + mock.press("y"); + mock.press("i"); + mock.press("w"); + mock.press("p"); + expect(answer.plainText).toBe("hellohello world"); + expect(mock.dispatched).not.toContain("prompt.paste"); + mock.close(); + mock.press("p"); + await flush(); + expect(clipboardWrites).toEqual(["main prompt\n", "hello", "hello", "main prompt\n"]); + expect(mock.dispatched).toContain("prompt.paste"); + }); + it("shows block in answer normal and line after returning to insert", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + mock.press("escape"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("block"); + mock.press("i"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + }); + it("keeps main count and pending operator while answering", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("2"); + mock.press("d"); + const answer = mock.open(); + mock.press("escape"); + mock.press("h"); + await flush(); + expect(answer.plainText).toBe("hello world"); + mock.close(); + mock.press("w"); + await flush(); + expect(mock.dispatched.filter((command) => command === "input.delete.word.forward")).toHaveLength(2); + }); + it("keeps one-shot normal isolated", async () => { + const mock = await setup(); + mock.press("o", { ctrl: true }); + mock.open(); + mock.press("escape"); + mock.press("h"); + await flush(); + mock.close(); + mock.press("h"); + await flush(); + mock.press("z"); + expect(mock.main.plainText).toContain("z"); + }); + it("re-anchors main visual mode if its prompt editor changed while answering", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("v"); + mock.open(); + mock.press("escape"); + mock.close(); + const nextPrompt = answerEditor("new prompt", "PROMPT"); + nextPrompt.cursorOffset = 0; + mock.focus(nextPrompt); + mock.press("l"); + await flush(); + expect(nextPrompt.selection).toEqual({ start: 0, end: 2 }); + }); + it("supports visual selection, visual Escape and deleting the selection", async () => { + const mock = await setup(); + const answer = mock.open(answerEditor("hello world")); + mock.press("escape"); + mock.press("g"); + mock.press("g"); + mock.press("v"); + mock.press("l"); + await flush(); + expect(answer.selection).toEqual({ start: 0, end: 2 }); + mock.press("escape"); + expect(mock.hostActions).toEqual([]); + expect(answer.selection).toBeUndefined(); + mock.press("g"); + mock.press("g"); + mock.press("v"); + mock.press("l"); + await flush(); + mock.press("d"); + await flush(); + expect(answer.plainText).toBe("llo world"); + }); + it("starts replacement and reopened answer editors fresh and isolates root/child", async () => { + const mock = await setup({ startMode: "normal" }); + const first = mock.open(); + mock.press("escape"); + mock.press("2"); + mock.press("d"); + const second = mock.open(answerEditor("child answer"), true); + mock.press("z"); + expect(second.plainText).toBe("child answerz"); + mock.press("escape"); + mock.press("h"); + await flush(); + expect(first.plainText).toBe("hello world"); + mock.close(); + const reopened = mock.open(answerEditor("again")); + mock.press("z"); + expect(reopened.plainText).toBe("againz"); + expect(mock.press("escape").propagationStopped).toBe(true); + mock.press("u"); + await flush(); + expect(mock.dispatched).toContain("input.undo"); + expect(reopened.plainText).toBe("againz"); + }); + it("inserts a printable leader in insert and keeps answer leader sequencing separate", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("space"); + const answer = mock.open(); + mock.press("space"); + expect(answer.plainText).toBe("hello world "); + mock.press("escape"); + expect(mock.press("h").propagationStopped).toBe(true); + await flush(); + mock.close(); + mock.dispatched.length = 0; + mock.press("h"); + await flush(); + expect(mock.dispatched).toEqual([]); + }); + it("empty answer j/k are editor motions, never prompt history", async () => { + const mock = await setup(); + mock.open(answerEditor("")); + mock.press("escape"); + mock.press("j"); + mock.press("k"); + await flush(); + expect(mock.dispatched).toEqual(["input.move.down", "input.move.up"]); + }); + it("preserves the global palette shortcut, yields modal keys and restores the same answer mode", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + await flush(); + expect(mock.dispatched).toContain("command.palette.show"); + mock.press("h"); + mock.press("escape"); + expect(mock.hostActions).toEqual(["palette", "palette:h"]); + expect(mock.renderer.currentFocusedEditor).toBe(answer); + expect(mock.press("h").propagationStopped).toBe(true); + await flush(); + expect(answer.plainText).toBe("hello world"); + expect(answer.cursorOffset).toBe(9); + expect(mock.main.plainText).toBe("main prompt"); + }); + for (const delayedPalette of [false, true]) { + for (const priorEdit of [false, true]) { + it(`orders a ${delayedPalette ? "delayed" : "queued"} palette before following keys${priorEdit ? " after a prior edit" : ""}`, async () => { + const mock = await setup({ delayedPalette }); + const answer = mock.open(); + mock.press("escape"); + if (priorEdit) mock.press("x"); + mock.press(":"); + mock.press("x"); + mock.press("return"); + await waitFor(() => mock.hostActions.includes("palette:submit")); + expect(mock.committedAnswers).toEqual([]); + expect(mock.hostActions).toEqual(["palette", "palette:submit"]); + expect(mock.paletteSubmissions).toEqual(["x"]); + expect(mock.dispatched).toEqual([...(priorEdit ? ["input.delete"] : []), "command.palette.show"]); + expect(answer.plainText).toBe(priorEdit ? "hello worl" : "hello world"); + expect(mock.renderer.currentFocusedEditor?.traits.status).toBe("PALETTE"); + expect(mock.renderer.currentFocusedEditor?.plainText).toBe("x"); + mock.press("escape"); + await waitFor(() => mock.renderer.currentFocusedEditor === answer); + mock.press("h"); + await flush(); + expect(answer.cursorOffset).toBe(9); + }); + } + } + it("orders native paste with keys during delayed palette focus", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + const metadata = { kind: "text" as const, mimeType: "text/plain" }; + mock.press("escape"); + mock.press(":"); + mock.press("a"); + const paste = mock.paste("x", metadata); + mock.press("b"); + mock.press("return"); + expect(paste.defaultPrevented).toBe(true); + await waitFor(() => mock.hostActions.includes("palette:submit")); + expect(mock.paletteSubmissions).toEqual(["axb"]); + expect(mock.pasteReplays).toEqual([{ text: "x", metadata }]); + expect(mock.committedAnswers).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("does not replay buffered paste into an answer after the palette closes", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + mock.press("x"); + mock.press("escape"); + const paste = mock.paste("y"); + expect(paste.defaultPrevented).toBe(true); + await waitFor(() => mock.renderer.currentFocusedEditor === answer && mock.hostActions.includes("palette")); + await flush(); + expect(mock.pasteReplays).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("does not replay buffered Enter into an answer after the palette closes", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + mock.press("x"); + mock.press("escape"); + mock.press("return"); + await waitFor(() => mock.renderer.currentFocusedEditor === answer && mock.hostActions.includes("palette")); + await flush(); + expect(mock.committedAnswers).toEqual([]); + expect(mock.paletteSubmissions).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("drops buffered palette keys on disposal", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + mock.press("x"); + mock.press("return"); + mock.dispose(); + await flush(); + expect(mock.hostActions).toEqual([]); + expect(mock.dispatched).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("does not drop a queued global palette command just because focus changes", async () => { + const mock = await setup(); + mock.open(); + mock.press("escape"); + mock.press(":"); + mock.close(); + await flush(); + expect(mock.dispatched).toContain("command.palette.show"); + }); + it("does not dispatch prompt-only actions from normal answer editing", async () => { + const mock = await setup(); + mock.open(); + mock.press("escape"); + for (const key of ["/", "[", "]", "{", "}", "p"]) mock.press(key); + await flush(); + expect(mock.dispatched).toEqual([]); + }); + it("leaves choices, generic forms, permission fields, modal dialogs and stale ANSWER traits alone", async () => { + const mock = await setup({ startMode: "normal" }); + mock.open(); + mock.focus(undefined); + expect(mock.press("h").propagationStopped).toBe(false); + const generic = mock.open(answerEditor("form", "FORM")); + expect(mock.press("h").propagationStopped).toBe(false); + expect(generic.plainText).toBe("formh"); + mock.close(); + mock.setPermission(true); + mock.focus(answerEditor("reason", "REJECT")); + expect(mock.press("h").propagationStopped).toBe(false); + mock.setPermission(false); + mock.setDialog(true); + mock.focus(answerEditor()); + expect(mock.press("h").propagationStopped).toBe(false); + mock.setDialog(false); + mock.setQuestion(false); + mock.focus(answerEditor()); + expect(mock.press("h").propagationStopped).toBe(false); + }); + if (version === "v1") { + for (const terminal of ["question.replied", "question.rejected"]) { + it(`balances child question event ownership after ${terminal} without host mode reporting`, async () => { + const mock = await setup({ noHostMode: true }); + mock.open(); + mock.setQuestion(false); + mock.emit("question.asked", "child"); + mock.press("escape"); + expect(mock.hostActions).toEqual([]); + mock.emit(terminal, "child"); + const answer = mock.renderer.currentFocusedEditor; + mock.press("h"); + await flush(); + expect(answer?.plainText).toBe("hello worlhd"); + expect(mock.dispatched).not.toContain("input.move.left"); + }); + } + it("requires an active question, not just a permission, on hosts without mode reporting", async () => { + const mock = await setup({ noHostMode: true, startMode: "normal" }); + mock.open(); + mock.setQuestion(false); + mock.setPermission(true); + mock.press("escape"); + expect(mock.toasts).not.toContain("NORMAL"); + mock.setPermission(false); + mock.open(); + mock.press("escape"); + expect(mock.hostActions).toEqual([]); + expect(mock.toasts).toContain("NORMAL"); + }); + } + it("does not style an unrelated overlay using the main Vim mode", async () => { + const mock = await setup({ startMode: "normal" }); + mock.open(answerEditor("reason", "REJECT")); + mock.setPermission(true); + const editor = mock.renderer.currentFocusedEditor; + if (!editor) throw new Error("No focused editor"); + editor.cursorStyle.style = "underline"; + await Bun.sleep(120); + expect(editor.cursorStyle.style).toBe("underline"); + }); + it("respects disabled Vim and restarts answer state when toggled", async () => { + const mock = await setup({ disabled: true }); + mock.open(); + mock.press("escape"); + expect(mock.hostActions).toEqual(["cancel"]); + await mock.toggle(); + const answer = mock.open(); + expect(mock.press("escape").propagationStopped).toBe(true); + mock.press("g"); + mock.press("g"); + mock.press("d"); + mock.press("i"); + mock.press("w"); + expect(answer.plainText).toBe(" world"); + mock.press("2"); + mock.press("d"); + await mock.toggle(); + await mock.toggle(); + expect(mock.renderer.currentFocusedEditor).toBe(answer); + mock.press("z"); + expect(answer.plainText).toBe("z world"); + mock.dispatched.length = 0; + mock.press("escape"); + mock.press("u"); + await flush(); + expect(mock.dispatched).toEqual(["input.undo"]); + expect(answer.plainText).toBe("z world"); + }); + it("requires actual focused-renderable ownership", async () => { + const mock = await setup(); + mock.open(); + mock.renderer.currentFocusedRenderable = {}; + mock.press("escape"); + expect(mock.hostActions).toEqual(["cancel"]); + expect(mock.toasts).not.toContain("NORMAL"); + }); + it("drops deferred undo and selection yank after answer focus changes", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("u"); + mock.close(); + await flush(); + expect(mock.dispatched).not.toContain("input.undo"); + mock.open(); + mock.press("escape"); + mock.press("v"); + mock.press("l"); + mock.press("y"); + mock.close(); + await flush(); + expect(clipboardWrites).toEqual([]); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe("main prompt"); + }); + for (const change of ["focus", "overlay", "dispose"] as const) { + it(`drops queued answer edits after ${change}`, async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("x"); + if (change === "focus") mock.close(); + if (change === "overlay") mock.setDialog(true); + if (change === "dispose") mock.dispose(); + await flush(); + expect(mock.dispatched).not.toContain("input.delete"); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe("main prompt"); + }); + } + it("drops main commands when a question opens before the host clears prompt focus", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("x"); + mock.setQuestion(true); + mock.setMode(version === "v1" ? "question" : "form"); + await flush(); + expect(mock.dispatched).not.toContain("input.delete"); + expect(mock.main.plainText).toBe("main prompt"); + }); + it("drops main commands queued before an answer took focus", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("x"); + const answer = mock.open(); + await flush(); + expect(answer.plainText).toBe("hello world"); + expect(mock.dispatched).not.toContain("input.delete"); + }); + }); +} diff --git a/test/support.ts b/test/support.ts new file mode 100644 index 0000000..fbfb60a --- /dev/null +++ b/test/support.ts @@ -0,0 +1,33 @@ +import type { Action } from "../src/vim"; + +export function cmds(actions: Action[]): string[] { + return actions.filter((a): a is Extract => a.type === "cmd").map((a) => a.cmd); +} + +export function cursorTos(actions: Action[]): number[] { + return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); +} + +export function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => a.type === "deleteRange") + .map((a) => ({ start: a.start, end: a.end })); +} + +export function saveUndoSnapshots(actions: Action[]): Action[] { + return actions.filter((a) => a.type === "saveUndoSnapshot"); +} + +export function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => a.type === "selectRange") + .map((a) => ({ start: a.start, end: a.end })); +} + +export const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ + name, + shift: opts?.shift ?? false, + ctrl: opts?.ctrl ?? false, + meta: opts?.meta ?? false, + super: opts?.super ?? false, +}); diff --git a/test/v2.test.ts b/test/v2.test.ts new file mode 100644 index 0000000..8b67829 --- /dev/null +++ b/test/v2.test.ts @@ -0,0 +1,428 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import plugin from "../src/index"; +import { createV2Facade, 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 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 }, + cursorStyle: { style: "line", blinking: true }, + insertText(value: string) { + this.plainText += value; + }, + }; + const renderer = { + currentFocusedEditor: editor, + get currentFocusedRenderable() { + return focused ? renderer.currentFocusedEditor : 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); + 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); + }, + }, + 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, + selections, + setEditor: (value: typeof editor) => { + renderer.currentFocusedEditor = value; + }, + 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("only exposes a parentID for child sessions in the shared controller contract (#79)", () => { + const mock = host({ updateCheck: false }); + const { api, dispose } = createV2Facade(mock.context); + cleanups.push(dispose); + expect(api.state.session.get("root")?.parentID).toBeUndefined(); + expect(api.state.session.get("child")?.parentID).toBe("root"); + }); + + for (const vimMode of ["normal", "visual", "insert"] as const) { + it(`passes child-session keys through and preserves ${vimMode} mode on return (#79)`, async () => { + const mock = host({ updateCheck: false, startMode: vimMode === "insert" ? "insert" : "normal" }); + mock.setRoute({ type: "session", sessionID: "root" }); + await start(mock); + if (vimMode === "visual") expect(mock.press("v").stopped).toBe(true); + + // A route can change before the host clears the previous prompt's focus. + mock.setRoute({ type: "session", sessionID: "child" }); + for (const key of ["escape", "i", "h", "k", "l", "return", "tab"]) { + expect(mock.press(key)).toEqual({ prevented: false, stopped: false }); + } + await Bun.sleep(20); + expect(mock.dispatched).toEqual([]); + + mock.setRoute({ type: "session", sessionID: "root" }); + const key = vimMode === "visual" ? "j" : vimMode === "insert" ? "return" : "h"; + const command = + vimMode === "visual" ? "input.select.down" : vimMode === "insert" ? "input.newline" : "input.move.left"; + expect(mock.press(key)).toEqual({ prevented: true, stopped: true }); + await Bun.sleep(20); + expect(mock.dispatched).toEqual([command]); + }); + + it(`yields to the Composer on parent and child routes without changing ${vimMode} mode (#79)`, async () => { + const mock = host({ updateCheck: false, startMode: vimMode === "insert" ? "insert" : "normal" }); + mock.setRoute({ type: "session", sessionID: "root" }); + await start(mock); + if (vimMode === "visual") expect(mock.press("v").stopped).toBe(true); + + mock.setMode("composer"); + for (const sessionID of ["root", "child"]) { + mock.setRoute({ type: "session", sessionID }); + for (const key of ["h", "j", "k", "l", "escape", "return", "tab"]) { + expect(mock.press(key)).toEqual({ prevented: false, stopped: false }); + } + } + await Bun.sleep(20); + expect(mock.dispatched).toEqual([]); + + mock.setRoute({ type: "session", sessionID: "root" }); + mock.setMode("base"); + const key = vimMode === "visual" ? "j" : vimMode === "insert" ? "return" : "h"; + const command = + vimMode === "visual" ? "input.select.down" : vimMode === "insert" ? "input.newline" : "input.move.left"; + expect(mock.press(key).stopped).toBe(true); + await Bun.sleep(20); + expect(mock.dispatched).toEqual([command]); + }); + } + + 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); + }); + + 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); + 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/test/vim.test.ts b/test/vim.test.ts deleted file mode 100644 index 489c380..0000000 --- a/test/vim.test.ts +++ /dev/null @@ -1,1882 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; -import { - type Action, - createVimState, - endOfWord, - findChar, - firstNonBlank, - finishOneShotIfComplete, - handleInsertKey, - handleNormalKey, - handleVisualKey, - type PromptAccess, - toggleVimMode, - translateKey, - type VimState, -} from "../src/vim"; - -function cmds(actions: Action[]): string[] { - return actions.filter((a): a is Extract => a.type === "cmd").map((a) => a.cmd); -} - -function cursorTos(actions: Action[]): number[] { - return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); -} - -function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { - return actions - .filter((a): a is Extract => a.type === "deleteRange") - .map((a) => ({ start: a.start, end: a.end })); -} - -function saveUndoSnapshots(actions: Action[]): Action[] { - return actions.filter((a) => a.type === "saveUndoSnapshot"); -} - -function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { - return actions - .filter((a): a is Extract => a.type === "selectRange") - .map((a) => ({ start: a.start, end: a.end })); -} - -const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ - name, - shift: opts?.shift ?? false, - ctrl: opts?.ctrl ?? false, - meta: opts?.meta ?? false, - super: opts?.super ?? false, -}); - -const mockPrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "hello world\nsecond line\nthird line", -}; - -const emptyPrompt: PromptAccess = { - getLine: () => "", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "", -}; - -let state: VimState; - -beforeEach(() => { - state = createVimState(); - state.mode = "normal"; -}); - -// ── createVimState ─────────────────────────────────────────── - -describe("createVimState", () => { - it("initializes disabled: false", () => { - const s = createVimState(); - expect(s.disabled).toBe(false); - }); - - it("initializes without a remembered character find", () => { - expect(createVimState().lastFind).toBeNull(); - }); -}); - -// ── toggleVimMode ──────────────────────────────────────────── - -describe("toggleVimMode", () => { - it("flips disabled from false to true", () => { - const s = createVimState(); - s.disabled = false; - toggleVimMode(s); - expect(s.disabled).toBe(true); - }); - - it("flips disabled from true to false", () => { - const s = createVimState(); - s.disabled = true; - toggleVimMode(s); - expect(s.disabled).toBe(false); - }); - - it("resets mode to insert when disabling", () => { - const s = createVimState(); - s.mode = "normal"; - s.pendingOp = "d"; - s.count = 3; - toggleVimMode(s); - expect(s.mode).toBe("insert"); - expect(s.pendingOp).toBeNull(); - expect(s.pendingChar).toBeNull(); - expect(s.count).toBe(0); - expect(s.oneShotNormal).toBe(false); - }); - - it("returns a toast and mode action when disabling", () => { - const s = createVimState(); - s.disabled = false; - const r = toggleVimMode(s); - expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode disabled" }); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("returns a toast action with 'Vim mode enabled' when enabling", () => { - const s = createVimState(); - s.disabled = true; - const r = toggleVimMode(s); - expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode enabled" }); - }); - - it("does not reset mode when enabling", () => { - const s = createVimState(); - s.disabled = true; - s.mode = "normal"; - toggleVimMode(s); - expect(s.mode).toBe("normal"); - }); - - it("returns consume: true", () => { - const s = createVimState(); - const r = toggleVimMode(s); - expect(r.consume).toBe(true); - }); -}); - -// ── endOfWord ────────────────────────────────────────────── - -describe("endOfWord", () => { - it("from start of word, moves to last char", () => { - expect(endOfWord("hello world", 0)).toBe(4); - }); - - it("from middle of word, moves to last char", () => { - expect(endOfWord("hello world", 2)).toBe(4); - }); - - it("from end of word, moves to end of next word", () => { - expect(endOfWord("hello world", 4)).toBe(10); - }); - - it("from whitespace, skips to end of next word", () => { - expect(endOfWord("hello world", 5)).toBe(10); - }); - - it("stops at punctuation boundary", () => { - expect(endOfWord("hello.world", 0)).toBe(4); - }); - - it("from punctuation, moves to end of punctuation run", () => { - expect(endOfWord("hello...world", 5)).toBe(7); - }); - - it("from end of punctuation, moves to end of next word", () => { - expect(endOfWord("a.b", 1)).toBe(2); - }); - - it("at end of text, stays put", () => { - expect(endOfWord("hello", 4)).toBe(4); - }); - - it("handles count > 1", () => { - expect(endOfWord("one two three", 0, 2)).toBe(6); - }); - - it("handles multiple whitespace", () => { - expect(endOfWord("hello world", 0)).toBe(4); - expect(endOfWord("hello world", 4)).toBe(12); - }); - - it("handles newlines as whitespace", () => { - expect(endOfWord("hello\nworld", 4)).toBe(10); - }); - - it("clamps at end of text", () => { - expect(endOfWord("hi", 0, 5)).toBe(1); - }); -}); - -describe("findChar", () => { - it("finds characters forward and backward without matching under the cursor", () => { - expect(findChar("abacad", 0, "a", "forward")).toBe(2); - expect(findChar("abacad", 5, "a", "backward")).toBe(4); - }); - - it("finds the counted occurrence", () => { - expect(findChar("abacad", 0, "a", "forward", 2)).toBe(4); - expect(findChar("abacad", 5, "a", "backward", 2)).toBe(2); - }); - - it("does not cross line boundaries", () => { - expect(findChar("abc\ndef", 2, "d", "forward")).toBeNull(); - expect(findChar("abc\ndef", 4, "c", "backward")).toBeNull(); - }); - - it("returns null when the character is absent", () => { - expect(findChar("abc", 0, "z", "forward")).toBeNull(); - }); -}); - -describe("firstNonBlank", () => { - it("finds the first non-blank character on the current line", () => { - expect(firstNonBlank("first\n second", 10)).toBe(8); - }); - - it("returns line start for an empty or whitespace-only line", () => { - expect(firstNonBlank("first\n\nthird", 6)).toBe(6); - expect(firstNonBlank("first\n \nthird", 8)).toBe(6); - expect(firstNonBlank("first\n", 6)).toBe(6); - }); -}); - -// ── translateKey ──────────────────────────────────────────── - -describe("translateKey", () => { - it("lowercase passes through", () => { - expect(translateKey(ev("h"))).toBe("h"); - }); - - it("shift+letter uppercases", () => { - expect(translateKey(ev("g", { shift: true }))).toBe("G"); - }); - - it("shift+4 → $", () => { - expect(translateKey(ev("4", { shift: true }))).toBe("$"); - }); - - it("shift+6 → ^", () => { - expect(translateKey(ev("6", { shift: true }))).toBe("^"); - }); - - it("shift+[ → {", () => { - expect(translateKey(ev("[", { shift: true }))).toBe("{"); - }); - - it("shift+] → }", () => { - expect(translateKey(ev("]", { shift: true }))).toBe("}"); - }); -}); - -// ── handleInsertKey ───────────────────────────────────────── - -describe("handleInsertKey", () => { - beforeEach(() => { - state.mode = "insert"; - }); - - it("escape → consume, mode normal", () => { - const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("enter → consume, input.newline", () => { - const r = handleInsertKey(state, "return", ev("return"), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.newline"); - }); - - it("ctrl+enter → consume, input.submit", () => { - const r = handleInsertKey(state, "return", ev("return", { ctrl: true }), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.submit"); - }); - - it("tab → consume, insertText tab", () => { - const r = handleInsertKey(state, "tab", ev("tab"), mockPrompt); - expect(r.consume).toBe(true); - expect(r.actions).toContainEqual({ type: "insertText", text: "\t" }); - }); - - it("regular key → passthrough", () => { - const r = handleInsertKey(state, "a", ev("a"), mockPrompt); - expect(r.consume).toBe(false); - }); - - it("escape mid-line moves cursor one left", () => { - const midLinePrompt: PromptAccess = { - getLine: () => "hello world", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 5, - getPlainText: () => "hello world", - }; - const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); - expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("escape at position 0 does not move cursor", () => { - const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); - expect(cursorTos(r.actions)).toEqual([]); - }); - - it("escape at start of line does not move cursor", () => { - const startOfLinePrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line"][n] ?? "", - getLineCount: () => 2, - getCursorLine: () => 1, - getCursorOffset: () => 12, - getPlainText: () => "hello world\nsecond line", - }; - const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); - expect(cursorTos(r.actions)).toEqual([]); - }); - - it("ctrl+o enters normal mode with oneShotNormal flag", () => { - const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(state.oneShotNormal).toBe(true); - expect(r.actions).toContainEqual({ type: "mode", mode: "(insert)" }); - }); - - it("ctrl+o emits (insert) mode action", () => { - const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); - expect(r.actions.some((a) => a.type === "mode" && a.mode === "(insert)")).toBe(true); - }); -}); - -// ── handleNormalKey — motions ─────────────────────────────── - -describe("handleNormalKey — motions", () => { - it("h dispatches input.move.left", () => { - const r = handleNormalKey(state, "h", ev("h"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.left"]); - }); - - it("j dispatches input.move.down", () => { - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.down"]); - }); - - it("k dispatches input.move.up", () => { - const r = handleNormalKey(state, "k", ev("k"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.up"]); - }); - - it("l dispatches input.move.right", () => { - const r = handleNormalKey(state, "l", ev("l"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.right"]); - }); - - it("3j dispatches input.move.down 3 times", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.down", "input.move.down", "input.move.down"]); - }); - - it("G dispatches input.buffer.end", () => { - const r = handleNormalKey(state, "G", ev("g", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.buffer.end"]); - }); - - it("0 dispatches input.line.home", () => { - const r = handleNormalKey(state, "0", ev("0"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.home"]); - }); - - it("0 after count > 0 accumulates as digit", () => { - handleNormalKey(state, "1", ev("1"), mockPrompt); - handleNormalKey(state, "0", ev("0"), mockPrompt); - expect(state.count).toBe(10); - }); - - it("g sets pendingChar, no actions", () => { - const r = handleNormalKey(state, "g", ev("g"), mockPrompt); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("g"); - }); -}); - -describe("handleNormalKey — character-find motions", () => { - const findPrompt: PromptAccess = { - getLine: () => "abacad", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "abacad", - }; - - it("f{char} moves to the next matching character", () => { - const pending = handleNormalKey(state, "f", ev("f"), findPrompt); - expect(pending.actions).toEqual([]); - expect(state.pendingChar).toBe("f"); - - const r = handleNormalKey(state, "a", ev("a"), findPrompt); - expect(cursorTos(r.actions)).toEqual([2]); - expect(state.pendingChar).toBeNull(); - }); - - it("2f{char} moves to the second matching character", () => { - handleNormalKey(state, "2", ev("2"), findPrompt); - handleNormalKey(state, "f", ev("f"), findPrompt); - const r = handleNormalKey(state, "a", ev("a"), findPrompt); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("F{char} moves to the previous matching character", () => { - const backwardPrompt = { ...findPrompt, getCursorOffset: () => 5 }; - handleNormalKey(state, "F", ev("f", { shift: true }), backwardPrompt); - const r = handleNormalKey(state, "a", ev("a"), backwardPrompt); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("searches for Space using OpenCode's key name", () => { - const spacedPrompt = { ...findPrompt, getPlainText: () => "a b", getCursorOffset: () => 0 }; - handleNormalKey(state, "f", ev("f"), spacedPrompt); - const r = handleNormalKey(state, "space", ev("space"), spacedPrompt); - expect(cursorTos(r.actions)).toEqual([1]); - }); - - it("does nothing and clears state when the character is not found", () => { - handleNormalKey(state, "3", ev("3"), findPrompt); - handleNormalKey(state, "f", ev("f"), findPrompt); - const r = handleNormalKey(state, "z", ev("z"), findPrompt); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBeNull(); - expect(state.count).toBe(0); - }); - - it("Escape cancels a pending find", () => { - handleNormalKey(state, "f", ev("f"), findPrompt); - handleNormalKey(state, "escape", ev("escape"), findPrompt); - expect(state.pendingChar).toBeNull(); - }); -}); - -describe("handleNormalKey — repeat character-find motions", () => { - const text = "xaxbxcx"; - let cursor = 0; - const repeatPrompt: PromptAccess = { - getLine: () => text, - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => cursor, - getPlainText: () => text, - }; - - beforeEach(() => { - cursor = 0; - }); - - it("; repeats the latest forward find", () => { - handleNormalKey(state, "f", ev("f"), repeatPrompt); - const first = handleNormalKey(state, "x", ev("x"), repeatPrompt); - cursor = cursorTos(first.actions)[0]; - - const repeated = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(cursorTos(repeated.actions)).toEqual([4]); - expect(state.lastFind).toEqual({ char: "x", direction: "forward" }); - }); - - it("; repeats the latest backward find", () => { - cursor = 6; - handleNormalKey(state, "F", ev("f", { shift: true }), repeatPrompt); - const first = handleNormalKey(state, "x", ev("x"), repeatPrompt); - cursor = cursorTos(first.actions)[0]; - - const repeated = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(cursorTos(repeated.actions)).toEqual([2]); - }); - - it(", reverses the find without changing its stored direction", () => { - handleNormalKey(state, "f", ev("f"), repeatPrompt); - const first = handleNormalKey(state, "x", ev("x"), repeatPrompt); - cursor = cursorTos(first.actions)[0]; - const second = handleNormalKey(state, ";", ev(";"), repeatPrompt); - cursor = cursorTos(second.actions)[0]; - - const reversed = handleNormalKey(state, ",", ev(","), repeatPrompt); - expect(cursorTos(reversed.actions)).toEqual([2]); - expect(state.lastFind).toEqual({ char: "x", direction: "forward" }); - - cursor = cursorTos(reversed.actions)[0]; - const forwardAgain = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(cursorTos(forwardAgain.actions)).toEqual([4]); - }); - - it("2; repeats to the second occurrence", () => { - handleNormalKey(state, "f", ev("f"), repeatPrompt); - const first = handleNormalKey(state, "x", ev("x"), repeatPrompt); - cursor = cursorTos(first.actions)[0]; - handleNormalKey(state, "2", ev("2"), repeatPrompt); - - const repeated = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(cursorTos(repeated.actions)).toEqual([6]); - }); - - it("does nothing without a previous find and cancels a pending operator", () => { - handleNormalKey(state, "d", ev("d"), repeatPrompt); - const r = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(r.actions).toEqual([]); - expect(state.pendingOp).toBeNull(); - }); - - it("a failed explicit find replaces the remembered find", () => { - handleNormalKey(state, "f", ev("f"), repeatPrompt); - handleNormalKey(state, "x", ev("x"), repeatPrompt); - handleNormalKey(state, "f", ev("f"), repeatPrompt); - handleNormalKey(state, "z", ev("z"), repeatPrompt); - expect(state.lastFind).toEqual({ char: "z", direction: "forward" }); - }); - - it("a failed repeat preserves the remembered find", () => { - cursor = 6; - state.lastFind = { char: "x", direction: "forward" }; - const r = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(r.actions).toEqual([]); - expect(state.lastFind).toEqual({ char: "x", direction: "forward" }); - }); -}); - -// ── handleNormalKey — g prefix ───────────────────────────── - -describe("handleNormalKey — g prefix", () => { - it("gg moves cursor to buffer start", () => { - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "g", ev("g"), mockPrompt); - expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([0]); - expect(state.pendingChar).toBeNull(); - }); - - it("g then Escape cancels pending, no movement", () => { - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); - expect(r.actions).toEqual([]); - }); - - it("g then unknown key cancels pending, no movement", () => { - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "z", ev("z"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.pendingChar).toBeNull(); - expect(cursorTos(r.actions)).toEqual([]); - expect(cmds(r.actions)).toEqual([]); - }); - - it("5gg consumes count without crash", () => { - handleNormalKey(state, "5", ev("5"), mockPrompt); - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "g", ev("g"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.count).toBe(0); - }); -}); - -// ── handleNormalKey — e motion ───────────────────────────── - -describe("handleNormalKey — e motion", () => { - const ePrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line"][n] ?? "", - getLineCount: () => 2, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "hello world\nsecond line", - }; - - it("e returns cursorTo at end of current word", () => { - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("2e returns cursorTo at end of second word", () => { - handleNormalKey(state, "2", ev("2"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(cursorTos(r.actions)).toEqual([10]); - }); - - it("de deletes from cursor to end of word", () => { - handleNormalKey(state, "d", ev("d"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(state.mode).toBe("normal"); - // The deleteRange goes through finishUndoableChange, so the snapshot - // comes from a single source (the saveUndoSnapshot action), not index.ts. - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - }); - - it("ce deletes from cursor to end of word and enters insert", () => { - handleNormalKey(state, "c", ev("c"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(state.mode).toBe("insert"); - }); - - it("ye yanks from cursor to end of word", () => { - handleNormalKey(state, "y", ev("y"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(state.yankRegister).toBe("hello"); - expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); - }); -}); - -// ── handleNormalKey — operators ───────────────────────────── - -describe("handleNormalKey — operators", () => { - it("dd dispatches input.delete.line", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(state.pendingOp).toBe("d"); - const r = handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line"]); - }); - - it("2dd dispatches input.delete.line twice", () => { - handleNormalKey(state, "2", ev("2"), mockPrompt); - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); - }); - - it("dw dispatches input.delete.word.forward", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); - }); - - it("3dw saves one undo snapshot around the repeated deletes", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - expect(cmds(r.actions)).toEqual([ - "input.delete.word.forward", - "input.delete.word.forward", - "input.delete.word.forward", - ]); - }); - - it("d$ dispatches input.delete.to.line.end", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); - }); - - it("d0 dispatches input.delete.to.line.start", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "0", ev("0"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.start"]); - }); - - it("dj dispatches input.delete.line twice", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); - }); - - it("dk dispatches input.move.up + input.delete.line twice", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "k", ev("k"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.up", "input.delete.line", "input.delete.line"]); - }); - - it("cc dispatches input.delete.line, enters insert", () => { - handleNormalKey(state, "c", ev("c"), mockPrompt); - const r = handleNormalKey(state, "c", ev("c"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line"]); - expect(state.mode).toBe("insert"); - }); - - it("cw dispatches input.delete.word.forward, enters insert", () => { - handleNormalKey(state, "c", ev("c"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); - expect(state.mode).toBe("insert"); - }); - - it("yy sets yankRegister and toasts", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - const r = handleNormalKey(state, "y", ev("y"), mockPrompt); - expect(state.yankRegister).toBe("hello world\n"); - expect(r.actions.some((a) => a.type === "yank")).toBe(true); - expect(r.actions.some((a) => a.type === "toast")).toBe(true); - }); - - it("yw selects word forward and yanks", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); - - it("y$ selects to line end and yanks", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.select.line.end"]); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); - - it("y3w selects 3 words and yanks", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - handleNormalKey(state, "3", ev("3"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual([ - "input.select.word.forward", - "input.select.word.forward", - "input.select.word.forward", - ]); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); -}); - -describe("handleNormalKey — operators with character-find motions", () => { - const forwardPrompt: PromptAccess = { - getLine: () => "abcabc", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "abcabc", - }; - const backwardPrompt = { ...forwardPrompt, getCursorOffset: () => 5 }; - - it("df{char} deletes through the matching character", () => { - handleNormalKey(state, "d", ev("d"), forwardPrompt); - handleNormalKey(state, "f", ev("f"), forwardPrompt); - const r = handleNormalKey(state, "c", ev("c"), forwardPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 2 }]); - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - }); - - it("dF{char} excludes the matching character from the backward range", () => { - handleNormalKey(state, "d", ev("d"), backwardPrompt); - handleNormalKey(state, "F", ev("f", { shift: true }), backwardPrompt); - const r = handleNormalKey(state, "a", ev("a"), backwardPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 4, end: 5 }]); - }); - - it("cf{char} deletes through the match and enters insert mode", () => { - handleNormalKey(state, "c", ev("c"), forwardPrompt); - handleNormalKey(state, "f", ev("f"), forwardPrompt); - const r = handleNormalKey(state, "c", ev("c"), forwardPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 2 }]); - expect(state.mode).toBe("insert"); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("yF{char} excludes the matching character from the backward range", () => { - handleNormalKey(state, "y", ev("y"), backwardPrompt); - handleNormalKey(state, "F", ev("f", { shift: true }), backwardPrompt); - const r = handleNormalKey(state, "a", ev("a"), backwardPrompt); - expect(state.yankRegister).toBe("bc"); - expect(r.actions).toContainEqual({ type: "yank", text: "bc" }); - }); - - it("d2f{char} uses the motion count", () => { - handleNormalKey(state, "d", ev("d"), forwardPrompt); - handleNormalKey(state, "2", ev("2"), forwardPrompt); - handleNormalKey(state, "f", ev("f"), forwardPrompt); - const r = handleNormalKey(state, "c", ev("c"), forwardPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); - }); - - it("leaves the buffer unchanged when the character is not found", () => { - handleNormalKey(state, "d", ev("d"), forwardPrompt); - handleNormalKey(state, "f", ev("f"), forwardPrompt); - const r = handleNormalKey(state, "z", ev("z"), forwardPrompt); - expect(r.actions).toEqual([]); - expect(state.pendingOp).toBeNull(); - }); -}); - -describe("handleNormalKey — operators with repeated character finds", () => { - const text = "xaxbxcx"; - let cursor = 2; - const repeatPrompt: PromptAccess = { - getLine: () => text, - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => cursor, - getPlainText: () => text, - }; - - beforeEach(() => { - cursor = 2; - }); - - it("d; includes the target when repeating forward", () => { - state.lastFind = { char: "x", direction: "forward" }; - handleNormalKey(state, "d", ev("d"), repeatPrompt); - const r = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 2, end: 4 }]); - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - }); - - it("d, excludes the target when reversing backward", () => { - cursor = 4; - state.lastFind = { char: "x", direction: "forward" }; - handleNormalKey(state, "d", ev("d"), repeatPrompt); - const r = handleNormalKey(state, ",", ev(","), repeatPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 3, end: 4 }]); - }); - - it("c, enters insert mode after applying the backward range", () => { - cursor = 4; - state.lastFind = { char: "x", direction: "forward" }; - handleNormalKey(state, "c", ev("c"), repeatPrompt); - const r = handleNormalKey(state, ",", ev(","), repeatPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 3, end: 4 }]); - expect(state.mode).toBe("insert"); - }); - - it("y; yanks through the forward target", () => { - state.lastFind = { char: "x", direction: "forward" }; - handleNormalKey(state, "y", ev("y"), repeatPrompt); - const r = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(state.yankRegister).toBe("xbx"); - expect(r.actions).toContainEqual({ type: "yank", text: "xbx" }); - }); - - it("d2; uses the repeat count", () => { - state.lastFind = { char: "x", direction: "forward" }; - handleNormalKey(state, "d", ev("d"), repeatPrompt); - handleNormalKey(state, "2", ev("2"), repeatPrompt); - const r = handleNormalKey(state, ";", ev(";"), repeatPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 2, end: 6 }]); - }); -}); - -// ── handleNormalKey — dG and cG ───────────────────────────── - -describe("handleNormalKey — dG and cG", () => { - const midPrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - getCursorOffset: () => 12, - getPlainText: () => "hello world\nsecond line\nthird line", - }; - - it("dG deletes from cursor to buffer end", () => { - handleNormalKey(state, "d", ev("d"), midPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); - expect(state.mode).toBe("normal"); - // Single snapshot source: the saveUndoSnapshot action, not a second - // push inside the deleteRange handler. - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - }); - - it("cG deletes from cursor to buffer end, enters insert", () => { - handleNormalKey(state, "c", ev("c"), midPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); - expect(state.mode).toBe("insert"); - }); - - it("yG still works (no regression)", () => { - handleNormalKey(state, "y", ev("y"), midPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); - expect(cmds(r.actions)).toContain("input.select.buffer.end"); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); - - it("dG on empty buffer doesn't crash", () => { - handleNormalKey(state, "d", ev("d"), emptyPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), emptyPrompt); - expect(r.consume).toBe(true); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 0 }]); - }); - - it("dG with cursor at end of buffer", () => { - const endPrompt: PromptAccess = { - getLine: (n) => ["hello"][n] ?? "", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 4, - getPlainText: () => "hello", - }; - handleNormalKey(state, "d", ev("d"), endPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), endPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 4, end: 4 }]); - }); -}); - -// ── handleNormalKey — shortcuts ───────────────────────────── - -describe("handleNormalKey — shortcuts", () => { - it("D dispatches input.delete.to.line.end", () => { - const r = handleNormalKey(state, "D", ev("d", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); - }); - - it("C dispatches input.delete.to.line.end and enters insert", () => { - const r = handleNormalKey(state, "C", ev("c", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); - expect(state.mode).toBe("insert"); - }); -}); - -// ── handleNormalKey — special keys ────────────────────────── - -describe("handleNormalKey — special keys", () => { - it(": dispatches command.palette.show", () => { - const r = handleNormalKey(state, ":", ev(":"), mockPrompt); - expect(cmds(r.actions)).toEqual(["command.palette.show"]); - }); - - it("/ dispatches session.timeline", () => { - const r = handleNormalKey(state, "/", ev("/"), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.timeline"]); - }); - - it("[ dispatches session.half.page.up", () => { - const r = handleNormalKey(state, "[", ev("["), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.half.page.up"]); - }); - - it("] dispatches session.half.page.down", () => { - const r = handleNormalKey(state, "]", ev("]"), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.half.page.down"]); - }); - - it("{ dispatches session.message.previous", () => { - const r = handleNormalKey(state, "{", ev("[", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.message.previous"]); - }); - - it("} dispatches session.message.next", () => { - const r = handleNormalKey(state, "}", ev("]", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.message.next"]); - }); - - it("X dispatches input.backspace", () => { - const r = handleNormalKey(state, "X", ev("x", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.backspace"]); - }); - - it("J dispatches input.line.end + input.delete", () => { - const r = handleNormalKey(state, "J", ev("j", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.end", "input.delete"]); - }); - - it("u triggers undo", () => { - const r = handleNormalKey(state, "u", ev("u"), mockPrompt); - expect(r.actions.some((a) => a.type === "undo")).toBe(true); - }); - - it("ctrl+r dispatches input.redo", () => { - const r = handleNormalKey(state, "r", ev("r", { ctrl: true }), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toEqual(["input.redo"]); - }); - - it("p with yankRegister set pastes", () => { - state.yankRegister = "yanked text\n"; - const r = handleNormalKey(state, "p", ev("p"), mockPrompt); - expect(r.actions.some((a) => a.type === "yank")).toBe(true); - expect(cmds(r.actions)).toContain("prompt.paste"); - }); - - it("meta combo → passthrough", () => { - const r = handleNormalKey(state, "c", ev("c", { meta: true }), mockPrompt); - expect(r.consume).toBe(false); - }); - - it("escape → passthrough, resets pendingOp", () => { - state.pendingOp = "d"; - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(r.consume).toBe(false); - expect(state.pendingOp).toBeNull(); - }); -}); - -// ── handleNormalKey — replace (r) ────────────────────────── - -describe("handleNormalKey — replace (r)", () => { - it("r sets pendingChar, consumes key, no commands", () => { - const r = handleNormalKey(state, "r", ev("r"), mockPrompt); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("r"); - }); - - it("r then a → input.delete + insertText('a'), stays normal", () => { - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "a", ev("a"), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toEqual(["input.delete"]); - expect(r.actions).toContainEqual({ type: "insertText", text: "a" }); - expect(state.mode).toBe("normal"); - expect(state.pendingChar).toBeNull(); - }); - - it("3ra → 3x input.delete + insertText('aaa')", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "a", ev("a"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); - expect(r.actions).toContainEqual({ type: "insertText", text: "aaa" }); - }); - - it("r then escape → cancels, no commands", () => { - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); - expect(cmds(r.actions)).toEqual([]); - }); - - it("r then digit → replaces with digit, not count", () => { - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "5", ev("5"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete"]); - expect(r.actions).toContainEqual({ type: "insertText", text: "5" }); - expect(state.count).toBe(0); - }); -}); - -// ── handleNormalKey — insert entries ──────────────────────── - -describe("handleNormalKey — insert entries", () => { - it("i enters insert", () => { - const r = handleNormalKey(state, "i", ev("i"), mockPrompt); - expect(state.mode).toBe("insert"); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("I moves to the first non-blank character and enters insert", () => { - const indentedPrompt: PromptAccess = { - getLine: () => " hello", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 6, - getPlainText: () => " hello", - }; - const r = handleNormalKey(state, "I", ev("i", { shift: true }), indentedPrompt); - expect(cursorTos(r.actions)).toEqual([2]); - expect(state.mode).toBe("insert"); - }); - - it("I uses line start on a blank line", () => { - const blankLinePrompt: PromptAccess = { - getLine: () => "", - getLineCount: () => 3, - getCursorLine: () => 1, - getCursorOffset: () => 6, - getPlainText: () => "first\n\nthird", - }; - const r = handleNormalKey(state, "I", ev("i", { shift: true }), blankLinePrompt); - expect(cursorTos(r.actions)).toEqual([6]); - expect(state.mode).toBe("insert"); - }); - - it("a dispatches input.move.right, enters insert", () => { - const r = handleNormalKey(state, "a", ev("a"), mockPrompt); - expect(cmds(r.actions)).toContain("input.move.right"); - expect(state.mode).toBe("insert"); - }); - - it("A dispatches input.line.end, enters insert", () => { - const r = handleNormalKey(state, "A", ev("a", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toContain("input.line.end"); - expect(state.mode).toBe("insert"); - }); - - it("o dispatches input.line.end + input.newline, enters insert", () => { - const r = handleNormalKey(state, "o", ev("o"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.end", "input.newline"]); - expect(state.mode).toBe("insert"); - }); - - it("O dispatches input.line.home + input.newline + input.move.up, enters insert", () => { - const r = handleNormalKey(state, "O", ev("o", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.home", "input.newline", "input.move.up"]); - expect(state.mode).toBe("insert"); - }); -}); - -// ── handleNormalKey — yy uses cursor position ───────────── - -describe("handleNormalKey — yy uses cursor position", () => { - it("yy yanks the line at getCursorLine, not a tracked counter", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - }; - handleNormalKey(state, "y", ev("y"), prompt); - const r = handleNormalKey(state, "y", ev("y"), prompt); - expect(state.yankRegister).toBe("second\n"); - expect(r.actions.some((a) => a.type === "yank" && a.text === "second\n")).toBe(true); - }); - - it("2yy from cursor line 1 yanks lines 1 and 2", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - }; - handleNormalKey(state, "2", ev("2"), prompt); - handleNormalKey(state, "y", ev("y"), prompt); - handleNormalKey(state, "y", ev("y"), prompt); - expect(state.yankRegister).toBe("second\nthird\n"); - }); - - it("yy on last line yanks that line", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 2, - }; - handleNormalKey(state, "y", ev("y"), prompt); - handleNormalKey(state, "y", ev("y"), prompt); - expect(state.yankRegister).toBe("third\n"); - }); -}); - -// ── handleNormalKey — history scrolling ───────────────────── - -describe("handleNormalKey — history scrolling", () => { - it("j dispatches prompt.history.next when input is empty", () => { - const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["prompt.history.next"]); - }); - - it("k dispatches prompt.history.previous when input is empty", () => { - const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["prompt.history.previous"]); - }); - - it("j dispatches input.move.down when input is non-empty", () => { - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.down"]); - }); - - it("3k dispatches prompt.history.previous 3 times when empty", () => { - handleNormalKey(state, "3", ev("3"), emptyPrompt); - const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["prompt.history.previous", "prompt.history.previous", "prompt.history.previous"]); - }); - - it("dj still deletes lines when input is empty", () => { - handleNormalKey(state, "d", ev("d"), emptyPrompt); - const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); - }); -}); - -// ── handleNormalKey — visual mode entry ──────────────────── - -describe("handleNormalKey — visual mode entry", () => { - it("v enters visual mode", () => { - const r = handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("visual"); - expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); - }); - - it("v clears pending operator", () => { - state.pendingOp = "d"; - handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(state.pendingOp).toBeNull(); - expect(state.mode).toBe("visual"); - }); - - it("V selects the current line and enters visual mode", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - getCursorOffset: () => 8, - getPlainText: () => "first\nsecond\nthird", - }; - - const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); - - expect(r.consume).toBe(true); - expect(state.mode).toBe("visual"); - expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 12 }]); - expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); - }); - - it("V selects the first line including its newline", () => { - const r = handleNormalKey(state, "V", ev("v", { shift: true }), mockPrompt); - - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 11 }]); - }); - - it("V selects the last line without requiring a trailing newline", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 2, - getCursorOffset: () => 15, - getPlainText: () => "first\nsecond\nthird", - }; - - const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); - - expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); - }); -}); - -// ── handleVisualKey — motions ────────────────────────────── - -describe("handleVisualKey — motions", () => { - beforeEach(() => { - state.mode = "visual"; - }); - - it("h dispatches input.select.left", () => { - const r = handleVisualKey(state, "h", ev("h")); - expect(cmds(r.actions)).toEqual(["input.select.left"]); - }); - - it("l dispatches input.select.right", () => { - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right"]); - }); - - it("j dispatches input.select.down", () => { - const r = handleVisualKey(state, "j", ev("j")); - expect(cmds(r.actions)).toEqual(["input.select.down"]); - }); - - it("k dispatches input.select.up", () => { - const r = handleVisualKey(state, "k", ev("k")); - expect(cmds(r.actions)).toEqual(["input.select.up"]); - }); - - it("w dispatches input.select.word.forward", () => { - const r = handleVisualKey(state, "w", ev("w")); - expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); - }); - - it("$ dispatches input.select.line.end", () => { - const r = handleVisualKey(state, "$", ev("4", { shift: true })); - expect(cmds(r.actions)).toEqual(["input.select.line.end"]); - }); - - it("3l dispatches input.select.right 3 times", () => { - handleVisualKey(state, "3", ev("3")); - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right", "input.select.right", "input.select.right"]); - }); - - it("G dispatches input.select.buffer.end", () => { - const r = handleVisualKey(state, "G", ev("g", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.select.buffer.end"]); - }); - - it("e selects from visual anchor to end of word", () => { - // "hello world" with cursor at 0, anchor at 0 → end of "hello" is offset 4 - state.visualAnchor = 0; - const r = handleVisualKey(state, "e", ev("e"), mockPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - }); - - it("2e selects from visual anchor to end of 2nd word", () => { - // "hello world" with cursor at 0, anchor at 0 → end of "world" is offset 10 - state.visualAnchor = 0; - handleVisualKey(state, "2", ev("2"), mockPrompt); - const r = handleVisualKey(state, "e", ev("e"), mockPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 10 }]); - }); - - it("e pressed twice extends selection to successive word ends", () => { - // "hello world" cursor at 0, anchor at 0 - // First e → end of "hello" (offset 4), must also emit cursorTo - // Second e (cursor now at 4) → end of "world" (offset 10) - state.visualAnchor = 0; - let cursorPos = 0; - const prompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => cursorPos }; - - const r1 = handleVisualKey(state, "e", ev("e"), prompt); - expect(selectRanges(r1.actions)).toEqual([{ start: 0, end: 4 }]); - expect(cursorTos(r1.actions)).toEqual([4]); - - // Simulate effect layer applying the cursorTo action - cursorPos = cursorTos(r1.actions)[0]; - - const r2 = handleVisualKey(state, "e", ev("e"), prompt); - expect(selectRanges(r2.actions)).toEqual([{ start: 0, end: 10 }]); - expect(cursorTos(r2.actions)).toEqual([10]); - }); - - it("f{char} extends the selection to the next matching character", () => { - state.visualAnchor = 0; - handleVisualKey(state, "f", ev("f"), mockPrompt); - const r = handleVisualKey(state, "o", ev("o"), mockPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("F{char} extends the selection to the previous matching character", () => { - state.visualAnchor = 10; - const backwardPrompt = { ...mockPrompt, getCursorOffset: () => 10 }; - handleVisualKey(state, "F", ev("f", { shift: true }), backwardPrompt); - const r = handleVisualKey(state, "o", ev("o"), backwardPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 10, end: 7 }]); - expect(cursorTos(r.actions)).toEqual([7]); - }); - - it("2f{char} extends the selection to the second match", () => { - state.visualAnchor = 0; - const findPrompt = { ...mockPrompt, getPlainText: () => "abacad" }; - handleVisualKey(state, "2", ev("2"), findPrompt); - handleVisualKey(state, "f", ev("f"), findPrompt); - const r = handleVisualKey(state, "a", ev("a"), findPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - }); - - it("Escape cancels a pending find and exits visual mode", () => { - handleVisualKey(state, "f", ev("f"), mockPrompt); - const r = handleVisualKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "clearSelection" }); - }); - - it("; repeats the remembered find in visual mode", () => { - state.visualAnchor = 0; - state.lastFind = { char: "x", direction: "forward" }; - const prompt = { ...mockPrompt, getCursorOffset: () => 2, getPlainText: () => "xaxbxcx" }; - const r = handleVisualKey(state, ";", ev(";"), prompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it(", reverses the remembered find in visual mode", () => { - state.visualAnchor = 6; - state.lastFind = { char: "x", direction: "forward" }; - const prompt = { ...mockPrompt, getCursorOffset: () => 4, getPlainText: () => "xaxbxcx" }; - const r = handleVisualKey(state, ",", ev(","), prompt); - expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 2 }]); - expect(state.lastFind.direction).toBe("forward"); - }); - - it("2; repeats to the second match in visual mode", () => { - state.visualAnchor = 0; - state.lastFind = { char: "x", direction: "forward" }; - const prompt = { ...mockPrompt, getCursorOffset: () => 2, getPlainText: () => "xaxbxcx" }; - handleVisualKey(state, "2", ev("2"), prompt); - const r = handleVisualKey(state, ";", ev(";"), prompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 6 }]); - }); - - it("does nothing when visual mode has no remembered find", () => { - state.count = 2; - const r = handleVisualKey(state, ";", ev(";"), mockPrompt); - expect(r.actions).toEqual([]); - expect(state.count).toBe(0); - }); - - it("g sets pendingChar, no actions", () => { - const r = handleVisualKey(state, "g", ev("g")); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("g"); - }); - - it("gg selects to buffer home", () => { - handleVisualKey(state, "g", ev("g")); - const r = handleVisualKey(state, "g", ev("g")); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toEqual(["input.select.buffer.home"]); - expect(state.pendingChar).toBeNull(); - }); - - it("g then Escape in visual cancels pending, stays visual", () => { - handleVisualKey(state, "g", ev("g")); - handleVisualKey(state, "escape", ev("escape")); - expect(state.pendingChar).toBeNull(); - // escape also exits visual mode - expect(state.mode).toBe("normal"); - }); -}); - -// ── handleVisualKey — operators ──────────────────────────── - -describe("handleVisualKey — operators", () => { - beforeEach(() => { - state.mode = "visual"; - }); - - it("d deletes selection and enters normal mode", () => { - const r = handleVisualKey(state, "d", ev("d")); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.backspace"); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("c deletes selection and enters insert mode", () => { - const r = handleVisualKey(state, "c", ev("c")); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.backspace"); - expect(state.mode).toBe("insert"); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("y yanks selection and enters normal mode", () => { - const r = handleVisualKey(state, "y", ev("y")); - expect(r.consume).toBe(true); - expect(r.actions).toContainEqual({ type: "yankSelection" }); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("x deletes selection (alias for d)", () => { - const r = handleVisualKey(state, "x", ev("x")); - expect(cmds(r.actions)).toContain("input.backspace"); - expect(state.mode).toBe("normal"); - }); -}); - -// ── handleVisualKey — exit and passthrough ───────────────── - -describe("handleVisualKey — exit and passthrough", () => { - beforeEach(() => { - state.mode = "visual"; - }); - - it("Escape exits visual mode and clears selection", () => { - const r = handleVisualKey(state, "escape", ev("escape")); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "clearSelection" }); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("v exits visual mode and clears selection", () => { - const r = handleVisualKey(state, "v", ev("v")); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "clearSelection" }); - }); - - it("meta combo passes through", () => { - const r = handleVisualKey(state, "c", ev("c", { meta: true })); - expect(r.consume).toBe(false); - }); - - it("ctrl combo passes through", () => { - const r = handleVisualKey(state, "x", ev("x", { ctrl: true })); - expect(r.consume).toBe(false); - }); - - it("unrecognized key is consumed (no typing in visual)", () => { - const r = handleVisualKey(state, "z", ev("z")); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - }); -}); - -// ── Ctrl+O one-shot normal mode ─────────────────────────── - -describe("Ctrl+O one-shot normal mode", () => { - function enterOneShot() { - state.mode = "insert"; - handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); - } - - it("w auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("3w auto-returns to insert after count is consumed", () => { - enterOneShot(); - handleNormalKey(state, "3", ev("3"), mockPrompt); - expect(state.oneShotNormal).toBe(true); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("dw auto-returns to insert after operator+motion", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("dd auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r1); - const r2 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - }); - - it("r{char} auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "r", ev("r"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "a", ev("a"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - }); - - it("gg auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "g", ev("g"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "g", ev("g"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - }); - - it("cw enters insert directly without double mode switch", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); - finishOneShotIfComplete(state, r1); - const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); - expect(modeActions).toHaveLength(1); - }); - - it("u auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "u", ev("u"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it("p auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "p", ev("p"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it(": auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, ":", ev(":"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it("e auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "e", ev("e"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it("escape during one-shot returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("v during one-shot cancels one-shot and enters visual", () => { - enterOneShot(); - handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(state.mode).toBe("visual"); - expect(state.oneShotNormal).toBe(false); - }); - - it("f{char} waits for its target then auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "f", ev("f"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - - const r2 = handleNormalKey(state, "o", ev("o"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(cursorTos(r2.actions)).toEqual([4]); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("; repeats a remembered find then auto-returns to insert", () => { - enterOneShot(); - state.lastFind = { char: "o", direction: "forward" }; - const r = handleNormalKey(state, ";", ev(";"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(cursorTos(r.actions)).toEqual([4]); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("sequential Ctrl+O usage works (flag resets cleanly)", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - // Second round - enterOneShot(); - expect(state.oneShotNormal).toBe(true); - const r2 = handleNormalKey(state, "b", ev("b"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("cc enters insert directly without double mode switch", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); - finishOneShotIfComplete(state, r1); - const r2 = handleNormalKey(state, "c", ev("c"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); - expect(modeActions).toHaveLength(1); - }); - - it("de auto-returns to insert (deleteRange path)", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "e", ev("e"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - expect(r2.actions.some((a) => a.type === "deleteRange")).toBe(true); - }); - - it("does not auto-return when not in one-shot mode", () => { - state.mode = "normal"; - state.oneShotNormal = false; - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("normal"); - }); -}); - -describe("version sync", () => { - it("VERSION matches package.json", async () => { - const pkg = await import("../package.json"); - const { VERSION } = await import("../src/version"); - expect(VERSION).toBe(pkg.version); - }); -}); - -// ── plugin init sanity check ────────────────────────────── - -describe("plugin init", () => { - it("tui() does not throw with a minimal mock API", async () => { - const plugin = (await import("../src/index")).default; - expect(plugin.id).toBe("vimcode"); - - // Minimal mock matching what OpenCode passes to tui(). - // Intentionally sparse — some fields are undefined or stubs, - // which is exactly the hostile environment we need to survive. - const dispatchCommand = () => ({ ok: false }); - const api = { - renderer: undefined, - ui: { toast: () => {}, dialog: { open: false } }, - keymap: { intercept: () => {}, dispatchCommand }, - route: { current: { name: "home", params: {} } }, - state: { session: { question: () => [], permission: () => [] } }, - lifecycle: { onDispose: () => {} }, - kv: { get: async () => undefined }, // empty object — the scenario that crashed v0.7.0 - }; - - // Should not throw with a sparse mock API. - // biome-ignore lint/suspicious/noExplicitAny: mock API doesn't match full plugin types - await plugin.tui(api as any, undefined, undefined as any); - }); -}); - -// ── undo snapshot integration ───────────────────────────── - -describe("undo snapshot — deleteRange + u", () => { - // Exercises the full pipeline: key event → handler → applyActions → editor state. - // The contract: u after dG restores the full buffer in one step via - // editBuffer.setText, not the host's per-line input.undo. - - function createMockEditor(text: string, cursor: number) { - let editorText = text; - let editorCursor = cursor; - const calls: { method: string; args: unknown[] }[] = []; - const editor = { - get plainText() { - return editorText; - }, - get cursorOffset() { - return editorCursor; - }, - set cursorOffset(v: number) { - editorCursor = v; - }, - visualCursor: { logicalRow: 1 }, - cursorStyle: { style: "block" as const, blinking: true }, - insertText: () => {}, - setSelectionInclusive: () => {}, - editorView: { resetSelection: () => {} }, - editBuffer: { - deleteRange: (sl: number, sc: number, el: number, ec: number) => { - calls.push({ method: "deleteRange", args: [sl, sc, el, ec] }); - editorText = editorText.substring(0, cursor); - }, - setText: (t: string) => { - calls.push({ method: "setText", args: [t] }); - editorText = t; - }, - }, - }; - return { editor, calls, getText: () => editorText, getCursor: () => editorCursor }; - } - - async function setup(text: string, cursor: number) { - const plugin = (await import("../src/index")).default; - const { editor, calls, getText, getCursor } = createMockEditor(text, cursor); - const dispatched: string[] = []; - // biome-ignore lint/suspicious/noExplicitAny: test mock - let handler: (ctx: any) => void; - - const api = { - renderer: { currentFocusedEditor: editor, currentFocusedRenderable: editor }, - ui: { toast: () => {}, dialog: { open: false } }, - keymap: { - intercept: (_e: string, h: typeof handler) => { - handler = h; - }, - dispatchCommand: (cmd: string) => { - dispatched.push(cmd); - return { ok: false }; - }, - }, - route: { current: { name: "home", params: {} } }, - state: { session: { question: () => [], permission: () => [] } }, - lifecycle: { onDispose: () => {} }, - kv: {}, - }; - - // biome-ignore lint/suspicious/noExplicitAny: mock API - await plugin.tui(api as any, undefined, undefined as any); - - const press = (name: string, opts: Record = {}) => { - handler?.({ event: { name, eventType: "press", ...opts }, consume: () => {} }); - }; - - // Enter normal mode - press("escape"); - - return { press, calls, dispatched, getText, getCursor }; - } - - it("u after dG restores the full buffer via editBuffer.setText", async () => { - const original = "hello world\nsecond line\nthird line"; - const { press, calls, dispatched, getCursor } = await setup(original, 12); - - press("d"); - press("g", { shift: true }); - expect(calls.some((c) => c.method === "deleteRange")).toBe(true); - - calls.length = 0; - press("u"); - - expect(calls).toContainEqual({ method: "setText", args: [original] }); - expect(getCursor()).toBe(12); - expect(dispatched).not.toContain("input.undo"); - }); - - it("u after dG then a motion falls back to host input.undo", async () => { - const { press, calls, dispatched } = await setup("hello world\nsecond line\nthird line", 12); - - press("d"); - press("g", { shift: true }); - expect(calls.some((c) => c.method === "deleteRange")).toBe(true); - - // h dispatches input.move.left (a cmd action), invalidating the snapshot - press("h"); - - calls.length = 0; - dispatched.length = 0; - press("u"); - - expect(calls.every((c) => c.method !== "setText")).toBe(true); - // input.undo is dispatched via setTimeout - await new Promise((r) => setTimeout(r, 20)); - expect(dispatched).toContain("input.undo"); - }); - - it("u after 3dw restores the full buffer via editBuffer.setText", async () => { - const original = "hello world second line third line"; - const { press, calls, dispatched, getCursor } = await setup(original, 0); - - press("3"); - press("d"); - press("w"); - - calls.length = 0; - press("u"); - - expect(calls).toContainEqual({ method: "setText", args: [original] }); - expect(getCursor()).toBe(0); - expect(dispatched).not.toContain("input.undo"); - }); - - it("u after 3dw then dd unwinds the snapshot stack one step per press", async () => { - const original = "hello world second line third line"; - const { press, calls, dispatched } = await setup(original, 0); - - // Two stacked undoable changes → two snapshots on the stack. - press("3"); - press("d"); - press("w"); - press("d"); - press("d"); - - // First u pops the dd snapshot, second pops the 3dw snapshot — each a - // local restore via setText, never the host's input.undo. - calls.length = 0; - dispatched.length = 0; - press("u"); - expect(calls.some((c) => c.method === "setText")).toBe(true); - expect(dispatched).not.toContain("input.undo"); - - calls.length = 0; - press("u"); - expect(calls.some((c) => c.method === "setText")).toBe(true); - expect(dispatched).not.toContain("input.undo"); - - // Stack is now empty — a third u falls through to host undo. - calls.length = 0; - press("u"); - expect(calls.every((c) => c.method !== "setText")).toBe(true); - await new Promise((r) => setTimeout(r, 20)); - expect(dispatched).toContain("input.undo"); - }); - - it("u after 3dw then an insert-mode edit falls back to host input.undo", async () => { - const { press, calls, dispatched } = await setup("hello world second line third line", 0); - - press("3"); - press("d"); - press("w"); - - // Enter insert and modify the buffer. The insert edit emits an - // insertText action, which clears the vim snapshot stack. - press("i"); - press("tab"); - press("escape"); - - calls.length = 0; - dispatched.length = 0; - press("u"); - - expect(calls.every((c) => c.method !== "setText")).toBe(true); - // input.undo is dispatched via setTimeout - await new Promise((r) => setTimeout(r, 20)); - expect(dispatched).toContain("input.undo"); - }); -}); diff --git a/test/vim/find.test.ts b/test/vim/find.test.ts new file mode 100644 index 0000000..569241d --- /dev/null +++ b/test/vim/find.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + createVimState, + finishOneShotIfComplete, + handleNormalKey, + handleVisualKey, + type PromptAccess, + type VimState, +} from "../../src/vim"; +import { findChar, firstNonBlank } from "../../src/vim/text"; +import { cursorTos, deleteRanges, ev, saveUndoSnapshots, selectRanges } from "../support"; + +let state: VimState; +let text: string; +let cursor: number; +const prompt: PromptAccess = { + getLine: (n) => text.split("\n")[n] ?? "", + getLineCount: () => text.split("\n").length, + getCursorLine: () => text.slice(0, cursor).split("\n").length - 1, + getCursorOffset: () => cursor, + getPlainText: () => text, +}; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; + text = "xaxbxcx"; + cursor = 0; +}); + +function press(key: string) { + const handler = state.mode === "visual" ? handleVisualKey : handleNormalKey; + const result = handler(state, key, ev(key), prompt); + finishOneShotIfComplete(state, result); + for (const offset of cursorTos(result.actions)) cursor = offset; + return result; +} + +describe("character-find algorithms", () => { + it("initializes without a remembered find", () => { + expect(state.lastFind).toBeNull(); + }); + it("finds counted occurrences without matching under the cursor", () => { + expect(findChar("abacad", 0, "a", "forward")).toBe(2); + expect(findChar("abacad", 5, "a", "backward")).toBe(4); + expect(findChar("abacad", 0, "a", "forward", 2)).toBe(4); + expect(findChar("abacad", 5, "a", "backward", 2)).toBe(2); + }); + it("does not cross lines, match newlines or accept absent/invalid characters", () => { + expect(findChar("abc\ndef", 2, "d", "forward")).toBeNull(); + expect(findChar("abc\ndef", 4, "c", "backward")).toBeNull(); + expect(findChar("abc\ndef", 0, "\n", "forward")).toBeNull(); + expect(findChar("abc", 0, "z", "forward")).toBeNull(); + expect(findChar("abc", 0, "return", "forward")).toBeNull(); + expect(findChar("", 0, "a", "forward")).toBeNull(); + }); + it("cannot repeat the first character backwards at column zero", () => { + expect(findChar("abc", 0, "a", "backward")).toBeNull(); + expect(findChar("abacad", 5, "a", "backward", 4)).toBeNull(); + }); + it("finds first nonblank or the start of an empty/blank line", () => { + expect(firstNonBlank("first\n \tsecond", 12)).toBe(8); + expect(firstNonBlank("first\n\nthird", 6)).toBe(6); + expect(firstNonBlank("first\n \nthird", 8)).toBe(6); + expect(firstNonBlank("first\n", 6)).toBe(6); + expect(firstNonBlank("", 0)).toBe(0); + expect(firstNonBlank("\nsecond", 0)).toBe(0); + }); +}); + +describe("normal character finds", () => { + it("waits for the character, moves and clears pending state", () => { + expect(press("f").actions).toEqual([]); + expect(state.pending).toEqual({ kind: "find", direction: "forward", op: undefined }); + expect(cursorTos(press("x").actions)).toEqual([2]); + expect(state.pending).toEqual({ kind: "none" }); + }); + it("supports forward/backward counts and remembers direction", () => { + press("2"); + press("f"); + press("x"); + expect(cursor).toBe(4); + press("F"); + press("x"); + expect(cursor).toBe(2); + expect(state.lastFind).toEqual({ char: "x", direction: "backward" }); + }); + for (const [key, char] of [ + ["space", " "], + ["tab", "\t"], + ["2", "2"], + [";", ";"], + [",", ","], + ]) { + it(`accepts ${key} as a character argument rather than a command`, () => { + text = `a${char}b`; + press("f"); + expect(cursorTos(press(key).actions)).toEqual([1]); + }); + } + it("clears operator/count on failure and remembers the attempted find", () => { + press("d"); + press("3"); + press("f"); + expect(press("z").actions).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.count).toBe(0); + expect(state.lastFind).toEqual({ char: "z", direction: "forward" }); + }); + it("cancels a pending find without replacing the last find", () => { + state.lastFind = { char: "x", direction: "backward" }; + press("d"); + press("2"); + press("f"); + press("escape"); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.count).toBe(0); + expect(state.lastFind).toEqual({ char: "x", direction: "backward" }); + }); + it("repeats, reverses without changing direction, then resumes forward", () => { + press("f"); + press("x"); + expect(cursorTos(press(";").actions)).toEqual([4]); + expect(cursorTos(press(",").actions)).toEqual([2]); + expect(state.lastFind).toEqual({ char: "x", direction: "forward" }); + expect(cursorTos(press(";").actions)).toEqual([4]); + }); + it("repeats backward finds", () => { + cursor = 6; + press("F"); + press("x"); + expect(cursorTos(press(";").actions)).toEqual([2]); + }); + it("supports counted repeat and preserves the remembered find on failure", () => { + press("f"); + press("x"); + press("2"); + expect(cursorTos(press(";").actions)).toEqual([6]); + expect(press(";").actions).toEqual([]); + expect(state.lastFind).toEqual({ char: "x", direction: "forward" }); + }); + it("a repeat without history cancels a pending operator", () => { + press("d"); + press("2"); + expect(press(";").actions).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.count).toBe(0); + }); + it("an unsuccessful explicit find replaces the remembered find", () => { + press("f"); + press("x"); + press("f"); + press("z"); + expect(state.lastFind).toEqual({ char: "z", direction: "forward" }); + }); + for (const op of ["d", "c", "y"] as const) { + for (const motion of ["f", "F", ";", ","] as const) { + it(`${op}${motion} preserves the fork's inclusive-forward/exclusive-backward ranges`, () => { + cursor = 4; + state.lastFind = { char: "x", direction: "forward" }; + press(op); + let result = press(motion); + if (motion === "f" || motion === "F") result = press("x"); + const range = motion === "F" || motion === "," ? { start: 3, end: 4 } : { start: 4, end: 6 }; + if (op === "y") { + expect(state.yankRegister).toBe(text.slice(range.start, range.end + 1)); + expect(result.actions).toEqual([{ type: "yank", text: state.yankRegister }]); + } else { + expect(deleteRanges(result.actions)).toEqual([range]); + expect(saveUndoSnapshots(result.actions)).toHaveLength(1); + expect(state.mode).toBe(op === "c" ? "insert" : "normal"); + } + expect(state.pending).toEqual({ kind: "none" }); + }); + } + } + for (const motion of ["f", ";"]) { + it(`supports d2${motion}`, () => { + state.lastFind = { char: "x", direction: "forward" }; + press("d"); + press("2"); + let result = press(motion); + if (motion === "f") result = press("x"); + expect(deleteRanges(result.actions)).toEqual([{ start: 0, end: 4 }]); + }); + } + it("holds one-shot normal until the character argument completes", () => { + state.oneShotNormal = true; + press("f"); + expect(state.mode).toBe("normal"); + const result = press("x"); + expect(cursor).toBe(2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(result.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + it("returns from a one-shot repeat", () => { + state.oneShotNormal = true; + state.lastFind = { char: "x", direction: "forward" }; + press(";"); + expect(cursor).toBe(2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + for (const [value, offset, target] of [ + [" hello", 6, 2], + ["first\n\nthird", 6, 6], + ] as const) { + it(`I enters insert at ${target} in ${JSON.stringify(value)}`, () => { + text = value; + cursor = offset; + const result = press("I"); + expect(cursor).toBe(target); + expect(state.mode).toBe("insert"); + expect(result.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + } +}); + +describe("visual character finds", () => { + beforeEach(() => { + state.mode = "visual"; + state.visualAnchor = 0; + }); + it("finds forward, repeats and reverses while keeping the anchor", () => { + press("f"); + expect(selectRanges(press("x").actions)).toEqual([{ start: 0, end: 2 }]); + expect(selectRanges(press(";").actions)).toEqual([{ start: 0, end: 4 }]); + expect(selectRanges(press(",").actions)).toEqual([{ start: 0, end: 2 }]); + expect(state.lastFind).toEqual({ char: "x", direction: "forward" }); + }); + it("extends backward from the original anchor", () => { + state.visualAnchor = 6; + cursor = 6; + press("F"); + expect(selectRanges(press("x").actions)).toEqual([{ start: 6, end: 4 }]); + expect(cursor).toBe(4); + }); + for (const motion of ["f", ";"]) { + it(`supports 2${motion} in visual mode`, () => { + state.lastFind = { char: "x", direction: "forward" }; + press("2"); + let result = press(motion); + if (motion === "f") result = press("x"); + expect(selectRanges(result.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursor).toBe(4); + }); + } + it("Escape cancels a find and exits visual without leaking count/pending state", () => { + press("2"); + press("f"); + const result = press("escape"); + expect(state.mode).toBe("normal"); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.count).toBe(0); + expect(result.actions).toContainEqual({ type: "clearSelection" }); + }); + it("does not move on failure or without a remembered find", () => { + press("2"); + expect(press(";").actions).toEqual([]); + expect(state.count).toBe(0); + press("f"); + expect(press("z").actions).toEqual([]); + expect(cursor).toBe(0); + expect(state.pending).toEqual({ kind: "none" }); + }); + it("still supports text objects after a character find", () => { + text = "one two three"; + press("f"); + press("t"); + press("i"); + expect(selectRanges(press("w").actions)).toEqual([{ start: 4, end: 6 }]); + }); +}); diff --git a/test/vim/insert.test.ts b/test/vim/insert.test.ts new file mode 100644 index 0000000..c6f5ae0 --- /dev/null +++ b/test/vim/insert.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, handleInsertKey, type PromptAccess, type VimState } from "../../src/vim"; +import { mockPrompt } from "../fixtures"; +import { cmds, cursorTos, ev } from "../support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── handleInsertKey ───────────────────────────────────────── + +describe("handleInsertKey", () => { + beforeEach(() => { + state.mode = "insert"; + }); + + it("escape → consume, mode normal", () => { + const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("enter → consume, input.newline", () => { + const r = handleInsertKey(state, "return", ev("return"), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.newline"); + }); + + it("ctrl+enter → consume, input.submit", () => { + const r = handleInsertKey(state, "return", ev("return", { ctrl: true }), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.submit"); + }); + + it("tab → consume, insertText tab", () => { + const r = handleInsertKey(state, "tab", ev("tab"), mockPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toContainEqual({ type: "insertText", text: "\t" }); + }); + + it("regular key → passthrough", () => { + const r = handleInsertKey(state, "a", ev("a"), mockPrompt); + expect(r.consume).toBe(false); + }); + + it("escape mid-line moves cursor one left", () => { + const midLinePrompt: PromptAccess = { + getLine: () => "hello world", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 5, + getPlainText: () => "hello world", + }; + const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("escape at position 0 does not move cursor", () => { + const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); + expect(cursorTos(r.actions)).toEqual([]); + }); + + it("escape at start of line does not move cursor", () => { + const startOfLinePrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line"][n] ?? "", + getLineCount: () => 2, + getCursorLine: () => 1, + getCursorOffset: () => 12, + getPlainText: () => "hello world\nsecond line", + }; + const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); + expect(cursorTos(r.actions)).toEqual([]); + }); + + it("ctrl+o enters normal mode with oneShotNormal flag", () => { + const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(state.oneShotNormal).toBe(true); + expect(r.actions).toContainEqual({ type: "mode", mode: "(insert)" }); + }); + + it("ctrl+o emits (insert) mode action", () => { + const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "(insert)")).toBe(true); + }); +}); diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts new file mode 100644 index 0000000..d32c74b --- /dev/null +++ b/test/vim/normal.test.ts @@ -0,0 +1,823 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, handleNormalKey, type PromptAccess, type VimState } from "../../src/vim"; +import { emptyPrompt, mockPrompt } from "../fixtures"; +import { cmds, cursorTos, deleteRanges, ev, saveUndoSnapshots, selectRanges } from "../support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── handleNormalKey — motions ─────────────────────────────── + +describe("handleNormalKey — motions", () => { + it("h dispatches input.move.left", () => { + const r = handleNormalKey(state, "h", ev("h"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.left"]); + }); + + it("j dispatches input.move.down", () => { + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.down"]); + }); + + it("k dispatches input.move.up", () => { + const r = handleNormalKey(state, "k", ev("k"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.up"]); + }); + + it("l dispatches input.move.right", () => { + const r = handleNormalKey(state, "l", ev("l"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.right"]); + }); + + it("3j dispatches input.move.down 3 times", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.down", "input.move.down", "input.move.down"]); + }); + + it("G dispatches input.buffer.end", () => { + const r = handleNormalKey(state, "G", ev("g", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.buffer.end"]); + }); + + it("0 dispatches input.line.home", () => { + const r = handleNormalKey(state, "0", ev("0"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.home"]); + }); + + it("0 after count > 0 accumulates as digit", () => { + handleNormalKey(state, "1", ev("1"), mockPrompt); + handleNormalKey(state, "0", ev("0"), mockPrompt); + expect(state.count).toBe(10); + }); + + it("g sets pending goto, no actions", () => { + const r = handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "goto" }); + }); +}); + +// ── handleNormalKey — g prefix ───────────────────────────── + +describe("handleNormalKey — g prefix", () => { + it("gg moves cursor to buffer start", () => { + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([0]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("g then Escape cancels pending, no movement", () => { + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(r.actions).toEqual([]); + }); + + it("g then unknown key cancels pending, no movement", () => { + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "z", ev("z"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.pending).toEqual({ kind: "none" }); + expect(cursorTos(r.actions)).toEqual([]); + expect(cmds(r.actions)).toEqual([]); + }); + + it("5gg consumes count without crash", () => { + handleNormalKey(state, "5", ev("5"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.count).toBe(0); + }); +}); + +// ── handleNormalKey — e motion ───────────────────────────── + +describe("handleNormalKey — e motion", () => { + const ePrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line"][n] ?? "", + getLineCount: () => 2, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "hello world\nsecond line", + }; + + it("e returns cursorTo at end of current word", () => { + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("2e returns cursorTo at end of second word", () => { + handleNormalKey(state, "2", ev("2"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(cursorTos(r.actions)).toEqual([10]); + }); + + it("de deletes from cursor to end of word", () => { + handleNormalKey(state, "d", ev("d"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("normal"); + // The deleteRange goes through finishUndoableChange, so the snapshot + // comes from a single source (the saveUndoSnapshot action), not index.ts. + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("ce deletes from cursor to end of word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("insert"); + }); + + it("ye yanks from cursor to end of word", () => { + handleNormalKey(state, "y", ev("y"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(state.yankRegister).toBe("hello"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); + }); +}); + +// ── handleNormalKey — text objects ───────────────────────── + +describe("handleNormalKey — text objects", () => { + // cursor sits inside "hello" + const wordPrompt: PromptAccess = { + getLine: (n) => ["hello world"][n] ?? "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 2, + getPlainText: () => "hello world", + }; + + it("d then i sets a pending inner text object, does not enter insert (#57)", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + const r = handleNormalKey(state, "i", ev("i"), wordPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "textobject", op: "d", around: false }); + expect(state.mode).toBe("normal"); + }); + + it("d then a sets a pending around text object", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + expect(state.pending).toEqual({ kind: "textobject", op: "d", around: true }); + }); + + it("diw deletes the inner word", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("normal"); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("ciw deletes the inner word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("insert"); + }); + + it("yiw yanks the inner word", () => { + handleNormalKey(state, "y", ev("y"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(state.yankRegister).toBe("hello"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); + expect(state.mode).toBe("normal"); + }); + + it("daw deletes the word and its trailing whitespace", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + expect(state.mode).toBe("normal"); + }); + + it("caw deletes a word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + expect(state.mode).toBe("insert"); + }); + + it("yaw yanks a word with trailing whitespace", () => { + handleNormalKey(state, "y", ev("y"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(state.yankRegister).toBe("hello "); + expect(state.mode).toBe("normal"); + }); + + it("an unresolved object char cancels the operator without editing", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "z", ev("z"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.mode).toBe("normal"); + }); + + it("standalone i still enters insert when no operator is pending", () => { + const r = handleNormalKey(state, "i", ev("i"), wordPrompt); + expect(state.mode).toBe("insert"); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); + }); +}); + +// ── handleNormalKey — pair text objects (end-to-end through the handler) ── + +describe("handleNormalKey — pair text objects", () => { + const prompt = (text: string, offset: number): PromptAccess => ({ + getLine: (n) => text.split("\n")[n] ?? "", + getLineCount: () => text.split("\n").length, + getCursorLine: () => 0, + getCursorOffset: () => offset, + getPlainText: () => text, + }); + + it('di" deletes inside double quotes', () => { + const p = prompt('say "hi"', 6); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, '"', ev('"'), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 5, end: 6 }]); + expect(state.mode).toBe("normal"); + }); + + it("ci( deletes inside parens and enters insert", () => { + const p = prompt("(abc)", 2); + handleNormalKey(state, "c", ev("c"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "(", ev("("), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 1, end: 3 }]); + expect(state.mode).toBe("insert"); + }); + + it("da( deletes around parens including the delimiters", () => { + const p = prompt("(abc)", 2); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "a", ev("a"), p); + const r = handleNormalKey(state, "(", ev("("), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + }); + + it("dib deletes inside the nearest bracket of any type", () => { + const p = prompt("[x]", 1); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "b", ev("b"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 1, end: 1 }]); + }); + + it("diq deletes inside the nearest quote of any type", () => { + const p = prompt("'hi'", 2); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "q", ev("q"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 1, end: 2 }]); + }); + + it("db still deletes a word backward — b stays a motion, not an object", () => { + const p = prompt("hello world", 6); + handleNormalKey(state, "d", ev("d"), p); + const r = handleNormalKey(state, "b", ev("b"), p); + expect(cmds(r.actions)).toEqual(["input.delete.word.backward"]); + }); +}); + +// ── handleNormalKey — operators ───────────────────────────── + +describe("handleNormalKey — operators", () => { + it("dd dispatches input.delete.line", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + expect(state.pending).toEqual({ kind: "operator", op: "d" }); + const r = handleNormalKey(state, "d", ev("d"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + }); + + it("2dd dispatches input.delete.line twice", () => { + handleNormalKey(state, "2", ev("2"), mockPrompt); + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "d", ev("d"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); + }); + + it("dw dispatches input.delete.word.forward", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); + }); + + it("3dw saves one undo snapshot around the repeated deletes", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + expect(cmds(r.actions)).toEqual([ + "input.delete.word.forward", + "input.delete.word.forward", + "input.delete.word.forward", + ]); + }); + + it("d$ dispatches input.delete.to.line.end", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); + }); + + it("d0 dispatches input.delete.to.line.start", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "0", ev("0"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.start"]); + }); + + it("dj dispatches input.delete.line twice", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); + }); + + it("dk dispatches input.move.up + input.delete.line twice", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "k", ev("k"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.up", "input.delete.line", "input.delete.line"]); + }); + + it("cc dispatches input.delete.line, enters insert", () => { + handleNormalKey(state, "c", ev("c"), mockPrompt); + const r = handleNormalKey(state, "c", ev("c"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + expect(state.mode).toBe("insert"); + }); + + it("cw dispatches input.delete.word.forward, enters insert", () => { + handleNormalKey(state, "c", ev("c"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); + expect(state.mode).toBe("insert"); + }); + + it("yy sets yankRegister and toasts", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "y", ev("y"), mockPrompt); + expect(state.yankRegister).toBe("hello world\n"); + expect(r.actions.some((a) => a.type === "yank")).toBe(true); + expect(r.actions.some((a) => a.type === "toast")).toBe(true); + }); + + it("yw selects word forward and yanks", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("y$ selects to line end and yanks", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.select.line.end"]); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("y3w selects 3 words and yanks", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual([ + "input.select.word.forward", + "input.select.word.forward", + "input.select.word.forward", + ]); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); +}); + +// ── handleNormalKey — dG and cG ───────────────────────────── + +describe("handleNormalKey — dG and cG", () => { + const midPrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 12, + getPlainText: () => "hello world\nsecond line\nthird line", + }; + + it("dG deletes from cursor to buffer end", () => { + handleNormalKey(state, "d", ev("d"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); + expect(state.mode).toBe("normal"); + // Single snapshot source: the saveUndoSnapshot action, not a second + // push inside the deleteRange handler. + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("cG deletes from cursor to buffer end, enters insert", () => { + handleNormalKey(state, "c", ev("c"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); + expect(state.mode).toBe("insert"); + }); + + it("yG still works (no regression)", () => { + handleNormalKey(state, "y", ev("y"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(cmds(r.actions)).toContain("input.select.buffer.end"); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("dG on empty buffer doesn't crash", () => { + handleNormalKey(state, "d", ev("d"), emptyPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), emptyPrompt); + expect(r.consume).toBe(true); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 0 }]); + }); + + it("dG with cursor at end of buffer", () => { + const endPrompt: PromptAccess = { + getLine: (n) => ["hello"][n] ?? "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 4, + getPlainText: () => "hello", + }; + handleNormalKey(state, "d", ev("d"), endPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), endPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 4, end: 4 }]); + }); +}); + +// ── handleNormalKey — shortcuts ───────────────────────────── + +describe("handleNormalKey — shortcuts", () => { + it("D dispatches input.delete.to.line.end", () => { + const r = handleNormalKey(state, "D", ev("d", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); + }); + + it("C dispatches input.delete.to.line.end and enters insert", () => { + const r = handleNormalKey(state, "C", ev("c", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); + expect(state.mode).toBe("insert"); + }); +}); + +// ── handleNormalKey — special keys ────────────────────────── + +describe("handleNormalKey — special keys", () => { + it(": dispatches command.palette.show", () => { + const r = handleNormalKey(state, ":", ev(":"), mockPrompt); + expect(cmds(r.actions)).toEqual(["command.palette.show"]); + }); + + it("/ dispatches session.timeline", () => { + const r = handleNormalKey(state, "/", ev("/"), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.timeline"]); + }); + + it("[ dispatches session.half.page.up", () => { + const r = handleNormalKey(state, "[", ev("["), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.half.page.up"]); + }); + + it("] dispatches session.half.page.down", () => { + const r = handleNormalKey(state, "]", ev("]"), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.half.page.down"]); + }); + + it("{ dispatches session.message.previous", () => { + const r = handleNormalKey(state, "{", ev("[", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.message.previous"]); + }); + + it("} dispatches session.message.next", () => { + const r = handleNormalKey(state, "}", ev("]", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.message.next"]); + }); + + it("X dispatches input.backspace", () => { + const r = handleNormalKey(state, "X", ev("x", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.backspace"]); + }); + + it("J dispatches input.line.end + input.delete", () => { + const r = handleNormalKey(state, "J", ev("j", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.end", "input.delete"]); + }); + + it("u triggers undo", () => { + const r = handleNormalKey(state, "u", ev("u"), mockPrompt); + expect(r.actions.some((a) => a.type === "undo")).toBe(true); + }); + + it("ctrl+r dispatches input.redo", () => { + const r = handleNormalKey(state, "r", ev("r", { ctrl: true }), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toEqual(["input.redo"]); + }); + + it("Enter in normal mode submits the prompt", () => { + const r = handleNormalKey(state, "return", ev("return"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.submit"]); + }); + + it("x deletes the character under the cursor", () => { + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete"]); + }); + + it("3x deletes three characters", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); + }); + + it("p with yankRegister set pastes", () => { + state.yankRegister = "yanked text\n"; + const r = handleNormalKey(state, "p", ev("p"), mockPrompt); + expect(r.actions.some((a) => a.type === "yank")).toBe(true); + expect(cmds(r.actions)).toContain("prompt.paste"); + }); + + it("meta combo → passthrough", () => { + const r = handleNormalKey(state, "c", ev("c", { meta: true }), mockPrompt); + expect(r.consume).toBe(false); + }); + + it("escape → passthrough, resets pending operator", () => { + state.pending = { kind: "operator", op: "d" }; + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(false); + expect(state.pending).toEqual({ kind: "none" }); + }); +}); + +// ── handleNormalKey — replace (r) ────────────────────────── + +describe("handleNormalKey — replace (r)", () => { + it("r sets pending replace, consumes key, no commands", () => { + const r = handleNormalKey(state, "r", ev("r"), mockPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "replace" }); + }); + + it("r then a → input.delete + insertText('a'), stays normal", () => { + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "a", ev("a"), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toEqual(["input.delete"]); + expect(r.actions).toContainEqual({ type: "insertText", text: "a" }); + expect(state.mode).toBe("normal"); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("3ra → 3x input.delete + insertText('aaa')", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "a", ev("a"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); + expect(r.actions).toContainEqual({ type: "insertText", text: "aaa" }); + }); + + it("r then escape → cancels, no commands", () => { + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(cmds(r.actions)).toEqual([]); + }); + + it("r then digit → replaces with digit, not count", () => { + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "5", ev("5"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete"]); + expect(r.actions).toContainEqual({ type: "insertText", text: "5" }); + expect(state.count).toBe(0); + }); +}); + +// ── handleNormalKey — insert entries ──────────────────────── + +describe("handleNormalKey — insert entries", () => { + it("i enters insert", () => { + const r = handleNormalKey(state, "i", ev("i"), mockPrompt); + expect(state.mode).toBe("insert"); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("a dispatches input.move.right, enters insert", () => { + const r = handleNormalKey(state, "a", ev("a"), mockPrompt); + expect(cmds(r.actions)).toContain("input.move.right"); + expect(state.mode).toBe("insert"); + }); + + it("A dispatches input.line.end, enters insert", () => { + const r = handleNormalKey(state, "A", ev("a", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toContain("input.line.end"); + expect(state.mode).toBe("insert"); + }); + + it("o dispatches input.line.end + input.newline, enters insert", () => { + const r = handleNormalKey(state, "o", ev("o"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.end", "input.newline"]); + expect(state.mode).toBe("insert"); + }); + + it("O dispatches input.line.home + input.newline + input.move.up, enters insert", () => { + const r = handleNormalKey(state, "O", ev("o", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.home", "input.newline", "input.move.up"]); + expect(state.mode).toBe("insert"); + }); +}); + +// ── handleNormalKey — yy uses cursor position ───────────── + +describe("handleNormalKey — yy uses cursor position", () => { + it("yy yanks the line at getCursorLine, not a tracked counter", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + }; + handleNormalKey(state, "y", ev("y"), prompt); + const r = handleNormalKey(state, "y", ev("y"), prompt); + expect(state.yankRegister).toBe("second\n"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "second\n")).toBe(true); + }); + + it("2yy from cursor line 1 yanks lines 1 and 2", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + }; + handleNormalKey(state, "2", ev("2"), prompt); + handleNormalKey(state, "y", ev("y"), prompt); + handleNormalKey(state, "y", ev("y"), prompt); + expect(state.yankRegister).toBe("second\nthird\n"); + }); + + it("yy on last line yanks that line", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 2, + }; + handleNormalKey(state, "y", ev("y"), prompt); + handleNormalKey(state, "y", ev("y"), prompt); + expect(state.yankRegister).toBe("third\n"); + }); +}); + +// ── handleNormalKey — history scrolling ───────────────────── + +describe("handleNormalKey — history scrolling", () => { + it("j dispatches prompt.history.next when input is empty", () => { + const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["prompt.history.next"]); + }); + + it("k dispatches prompt.history.previous when input is empty", () => { + const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["prompt.history.previous"]); + }); + + it("j dispatches input.move.down when input is non-empty", () => { + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.down"]); + }); + + it("3k dispatches prompt.history.previous 3 times when empty", () => { + handleNormalKey(state, "3", ev("3"), emptyPrompt); + const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["prompt.history.previous", "prompt.history.previous", "prompt.history.previous"]); + }); + + it("dj still deletes lines when input is empty", () => { + handleNormalKey(state, "d", ev("d"), emptyPrompt); + const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); + }); +}); + +// ── handleNormalKey — visual mode entry ──────────────────── + +describe("handleNormalKey — visual mode entry", () => { + it("v enters visual mode", () => { + const r = handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("visual"); + expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); + }); + + it("v clears pending operator", () => { + state.pending = { kind: "operator", op: "d" }; + handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.mode).toBe("visual"); + }); + + it("V selects the current line and enters visual mode", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 8, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(r.consume).toBe(true); + expect(state.mode).toBe("visual"); + expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 12 }]); + expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); + }); + + it("V selects the first line including its newline", () => { + const r = handleNormalKey(state, "V", ev("v", { shift: true }), mockPrompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 11 }]); + }); + + it("V selects the last line without requiring a trailing newline", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 2, + getCursorOffset: () => 15, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); + }); +}); + +// ── handleNormalKey — pending cleanup ────────────────────── + +describe("handleNormalKey — pending cleanup", () => { + it("dgg does not leave a dangling operator", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("dgg then w moves by word (the old dangling 'd' would have deleted it)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.word.forward"]); // NOT input.delete.word.forward + }); + + it("drx replaces the char and clears pending", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(r.actions.some((a) => a.type === "insertText")).toBe(true); + }); + + it("drx then w moves by word (no dangling delete-operator)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + handleNormalKey(state, "x", ev("x"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.word.forward"]); + }); +}); + +// ── handleNormalKey — arrow keys pass through (issue #63) ── + +describe("handleNormalKey — arrow keys pass through", () => { + // Arrows are host navigation, not vim motions. If vimcode consumes them, + // OpenCode can't exit the subagent view from normal mode (issue #63). + for (const arrow of ["up", "down", "left", "right"] as const) { + it(`${arrow} passes through to the host without consuming`, () => { + const r = handleNormalKey(state, arrow, ev(arrow), mockPrompt); + expect(r.consume).toBe(false); + expect(r.actions).toEqual([]); + }); + } +}); diff --git a/test/vim/state.test.ts b/test/vim/state.test.ts new file mode 100644 index 0000000..4a06f42 --- /dev/null +++ b/test/vim/state.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { createVimState, toggleVimMode } from "../../src/vim"; + +// ── createVimState ─────────────────────────────────────────── + +describe("createVimState", () => { + it("initializes disabled: false", () => { + const s = createVimState(); + expect(s.disabled).toBe(false); + }); +}); + +// ── toggleVimMode ──────────────────────────────────────────── + +describe("toggleVimMode", () => { + it("flips disabled from false to true", () => { + const s = createVimState(); + s.disabled = false; + toggleVimMode(s); + expect(s.disabled).toBe(true); + }); + + it("flips disabled from true to false", () => { + const s = createVimState(); + s.disabled = true; + toggleVimMode(s); + expect(s.disabled).toBe(false); + }); + + it("resets mode to insert when disabling", () => { + const s = createVimState(); + s.mode = "normal"; + s.pending = { kind: "operator", op: "d" }; + s.count = 3; + toggleVimMode(s); + expect(s.mode).toBe("insert"); + expect(s.pending).toEqual({ kind: "none" }); + expect(s.count).toBe(0); + expect(s.oneShotNormal).toBe(false); + }); + + it("returns a toast and mode action when disabling", () => { + const s = createVimState(); + s.disabled = false; + const r = toggleVimMode(s); + expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode disabled" }); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("returns a toast action with 'Vim mode enabled' when enabling", () => { + const s = createVimState(); + s.disabled = true; + const r = toggleVimMode(s); + expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode enabled" }); + }); + + it("does not reset mode when enabling", () => { + const s = createVimState(); + s.disabled = true; + s.mode = "normal"; + toggleVimMode(s); + expect(s.mode).toBe("normal"); + }); + + it("returns consume: true", () => { + const s = createVimState(); + const r = toggleVimMode(s); + expect(r.consume).toBe(true); + }); +}); diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts new file mode 100644 index 0000000..72b0fa9 --- /dev/null +++ b/test/vim/text.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from "bun:test"; +import { endOfWord } from "../../src/vim"; +import { + anyBracketRange, + anyQuoteRange, + bracketRange, + charKind, + currentLineRange, + isWhitespace, + quoteRange, + wordRange, +} from "../../src/vim/text"; + +// ── endOfWord ────────────────────────────────────────────── + +describe("endOfWord", () => { + it("from start of word, moves to last char", () => { + expect(endOfWord("hello world", 0)).toBe(4); + }); + + it("from middle of word, moves to last char", () => { + expect(endOfWord("hello world", 2)).toBe(4); + }); + + it("from end of word, moves to end of next word", () => { + expect(endOfWord("hello world", 4)).toBe(10); + }); + + it("from whitespace, skips to end of next word", () => { + expect(endOfWord("hello world", 5)).toBe(10); + }); + + it("stops at punctuation boundary", () => { + expect(endOfWord("hello.world", 0)).toBe(4); + }); + + it("from punctuation, moves to end of punctuation run", () => { + expect(endOfWord("hello...world", 5)).toBe(7); + }); + + it("from end of punctuation, moves to end of next word", () => { + expect(endOfWord("a.b", 1)).toBe(2); + }); + + it("at end of text, stays put", () => { + expect(endOfWord("hello", 4)).toBe(4); + }); + + it("handles count > 1", () => { + expect(endOfWord("one two three", 0, 2)).toBe(6); + }); + + it("handles multiple whitespace", () => { + expect(endOfWord("hello world", 0)).toBe(4); + expect(endOfWord("hello world", 4)).toBe(12); + }); + + it("handles newlines as whitespace", () => { + expect(endOfWord("hello\nworld", 4)).toBe(10); + }); + + it("clamps at end of text", () => { + expect(endOfWord("hi", 0, 5)).toBe(1); + }); +}); + +// ── isWhitespace ─────────────────────────────────────────── + +describe("isWhitespace", () => { + it("returns true for a space", () => { + expect(isWhitespace(" ")).toBe(true); + }); + + it("returns true for a tab", () => { + expect(isWhitespace("\t")).toBe(true); + }); + + it("returns true for a newline", () => { + expect(isWhitespace("\n")).toBe(true); + }); + + it("returns true for a carriage return", () => { + expect(isWhitespace("\r")).toBe(true); + }); + + it("returns false for a word character", () => { + expect(isWhitespace("a")).toBe(false); + }); + + it("returns false for punctuation", () => { + expect(isWhitespace(".")).toBe(false); + }); +}); + +// ── charKind ─────────────────────────────────────────────── + +describe("charKind", () => { + it("classifies a letter as word", () => { + expect(charKind("a")).toBe("word"); + }); + + it("classifies a digit as word", () => { + expect(charKind("7")).toBe("word"); + }); + + it("classifies an underscore as word", () => { + expect(charKind("_")).toBe("word"); + }); + + it("classifies whitespace as space", () => { + expect(charKind(" ")).toBe("space"); + expect(charKind("\n")).toBe("space"); + }); + + it("classifies punctuation as punct", () => { + expect(charKind(".")).toBe("punct"); + expect(charKind("-")).toBe("punct"); + }); +}); + +// ── currentLineRange ─────────────────────────────────────── + +describe("currentLineRange", () => { + it("returns {0,0} for empty text", () => { + expect(currentLineRange("", 0)).toEqual({ start: 0, end: 0 }); + }); + + it("spans a single line to its final char index", () => { + expect(currentLineRange("hello", 2)).toEqual({ start: 0, end: 4 }); + }); + + it("ends at the newline for a middle line", () => { + // "hello\nworld" — offset 8 is on the second (last) line; end clamps to len-1 + expect(currentLineRange("hello\nworld", 8)).toEqual({ start: 6, end: 10 }); + }); + + it("starts after the preceding newline", () => { + // offset 0 is on the first line, which ends at the first newline (index 5) + expect(currentLineRange("hello\nworld", 0)).toEqual({ start: 0, end: 5 }); + }); + + it("clamps an out-of-range offset into the text", () => { + expect(currentLineRange("hello", 99)).toEqual({ start: 0, end: 4 }); + }); +}); + +// ── wordRange (inner) ────────────────────────────────────── + +describe("wordRange (inner)", () => { + it("from mid-word, spans the whole word", () => { + expect(wordRange("hello world", 2, false)).toEqual({ start: 0, end: 4 }); + }); + + it("from the first char, spans the whole word", () => { + expect(wordRange("hello world", 0, false)).toEqual({ start: 0, end: 4 }); + }); + + it("from the last char, spans the whole word", () => { + expect(wordRange("hello world", 4, false)).toEqual({ start: 0, end: 4 }); + }); + + it("spans the second word", () => { + expect(wordRange("hello world", 6, false)).toEqual({ start: 6, end: 10 }); + }); + + it("on a single space, selects just that space", () => { + expect(wordRange("hello world", 5, false)).toEqual({ start: 5, end: 5 }); + }); + + it("on whitespace, selects the whole whitespace run", () => { + expect(wordRange("a b", 2, false)).toEqual({ start: 1, end: 3 }); + }); + + it("on punctuation, selects the punctuation char", () => { + expect(wordRange("a.b", 1, false)).toEqual({ start: 1, end: 1 }); + }); + + it("on a punctuation run, selects the whole run", () => { + expect(wordRange("a...b", 2, false)).toEqual({ start: 1, end: 3 }); + }); + + it("selects a single-char buffer", () => { + expect(wordRange("x", 0, false)).toEqual({ start: 0, end: 0 }); + }); + + it("returns null for empty text", () => { + expect(wordRange("", 0, false)).toBeNull(); + }); + + it("does not cross a newline", () => { + expect(wordRange("ab\ncd", 1, false)).toEqual({ start: 0, end: 1 }); + }); + + it("selects the word after a newline", () => { + expect(wordRange("ab\ncd", 3, false)).toEqual({ start: 3, end: 4 }); + }); + + it("returns null when the cursor sits on a newline", () => { + expect(wordRange("ab\ncd", 2, false)).toBeNull(); + }); + + it("clamps an out-of-range offset into the text", () => { + expect(wordRange("hello", 99, false)).toEqual({ start: 0, end: 4 }); + }); +}); + +// ── wordRange (around) ───────────────────────────────────── + +describe("wordRange (around)", () => { + it("includes trailing whitespace", () => { + expect(wordRange("hello world", 2, true)).toEqual({ start: 0, end: 5 }); + }); + + it("includes a whole trailing whitespace run", () => { + expect(wordRange("a b", 0, true)).toEqual({ start: 0, end: 3 }); + }); + + it("includes leading whitespace when there is no trailing whitespace", () => { + expect(wordRange("hello world", 8, true)).toEqual({ start: 5, end: 10 }); + }); + + it("on whitespace, includes the following word", () => { + expect(wordRange("a b", 2, true)).toEqual({ start: 1, end: 4 }); + }); + + it("does not extend an around-word onto a CR (CRLF safety)", () => { + expect(wordRange("word\r\nnext", 0, true)).toEqual({ start: 0, end: 3 }); + }); + + it("stops a whitespace run at a CR (CRLF safety)", () => { + // "a \r\nb": cursor on the space at index 1; the run must not swallow \r + expect(wordRange("a \r\nb", 1, false)).toEqual({ start: 1, end: 1 }); + }); +}); + +// ── bracketRange ─────────────────────────────────────────── + +describe("bracketRange", () => { + it("inner spans between the delimiters", () => { + expect(bracketRange("(abc)", 2, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("around includes the delimiters", () => { + expect(bracketRange("(abc)", 2, "(", ")", true)).toEqual({ start: 0, end: 4 }); + }); + + it("works with the cursor on the opening delimiter", () => { + expect(bracketRange("(abc)", 0, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("works with the cursor on the closing delimiter", () => { + expect(bracketRange("(abc)", 4, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("selects the innermost pair when nested", () => { + // ( a ( b ) c ) — cursor on b (index 3) picks the inner pair + expect(bracketRange("(a(b)c)", 3, "(", ")", false)).toEqual({ start: 3, end: 3 }); + }); + + it("selects the outer pair when the cursor is outside the inner one", () => { + expect(bracketRange("(a(b)c)", 1, "(", ")", false)).toEqual({ start: 1, end: 5 }); + }); + + it("spans multiple lines", () => { + // "(\nx\n)" — inner is everything between the parens, newlines included + expect(bracketRange("(\nx\n)", 2, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("returns null for an empty pair (nothing inside)", () => { + expect(bracketRange("()", 0, "(", ")", false)).toBeNull(); + }); + + it("around still selects an empty pair", () => { + expect(bracketRange("()", 0, "(", ")", true)).toEqual({ start: 0, end: 1 }); + }); + + it("returns null when there is no enclosing pair", () => { + expect(bracketRange("abc", 1, "(", ")", false)).toBeNull(); + }); + + it("returns null when the cursor is outside the pair", () => { + expect(bracketRange("(a)b", 3, "(", ")", false)).toBeNull(); + }); + + it("handles curly braces too", () => { + expect(bracketRange("x{ y }z", 3, "{", "}", true)).toEqual({ start: 1, end: 5 }); + }); +}); + +// ── quoteRange ───────────────────────────────────────────── + +describe("quoteRange", () => { + it("inner spans between the quotes", () => { + // say "hi" ok — cursor on the i (index 6) + expect(quoteRange('say "hi" ok', 6, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("around includes the quotes", () => { + expect(quoteRange('say "hi" ok', 6, '"', true)).toEqual({ start: 4, end: 7 }); + }); + + it("works with the cursor on the opening quote", () => { + expect(quoteRange('say "hi"', 4, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("works with the cursor on the closing quote", () => { + expect(quoteRange('say "hi"', 7, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("returns null for an empty pair", () => { + expect(quoteRange('a "" b', 2, '"', false)).toBeNull(); + }); + + it("around still selects an empty pair", () => { + expect(quoteRange('a "" b', 2, '"', true)).toEqual({ start: 2, end: 3 }); + }); + + it("pairs quotes left-to-right and picks the enclosing pair", () => { + // "a" "b" — cursor on b (index 5) selects the second pair + expect(quoteRange('"a" "b"', 5, '"', false)).toEqual({ start: 5, end: 5 }); + }); + + it("returns null when the cursor is between two pairs", () => { + expect(quoteRange('"a" "b"', 3, '"', false)).toBeNull(); + }); + + it("does not pair quotes across a newline", () => { + // "x"\n"y" — cursor on y is enclosed only by the second line's pair + expect(quoteRange('"x"\n"y"', 5, '"', false)).toEqual({ start: 5, end: 5 }); + }); + + it("returns null for an unpaired quote", () => { + expect(quoteRange('a "b', 3, '"', false)).toBeNull(); + }); + + it("returns null when there are no quotes", () => { + expect(quoteRange("abc", 1, '"', false)).toBeNull(); + }); +}); + +// ── anyQuoteRange (iq) ───────────────────────────────────── + +describe("anyQuoteRange", () => { + it("matches whichever quote type is present", () => { + expect(anyQuoteRange("say 'hi'", 6, false)).toEqual({ start: 5, end: 6 }); + }); + + it("picks the tightest enclosing quote when types nest", () => { + // "a 'b' c" — cursor on b: the single-quote pair is tighter than the double + expect(anyQuoteRange("\"a 'b' c\"", 4, false)).toEqual({ start: 4, end: 4 }); + }); + + it("supports around", () => { + expect(anyQuoteRange("say 'hi'", 6, true)).toEqual({ start: 4, end: 7 }); + }); + + it("returns null when no quote encloses the cursor", () => { + expect(anyQuoteRange("abc", 1, false)).toBeNull(); + }); +}); + +// ── anyBracketRange (ib) ─────────────────────────────────── + +describe("anyBracketRange", () => { + it("matches whichever bracket type is present", () => { + expect(anyBracketRange("a [x] b", 3, false)).toEqual({ start: 3, end: 3 }); + }); + + it("picks the tightest enclosing bracket when types nest", () => { + // ( [x] ) — cursor on x: the square-bracket pair is tighter than the paren + expect(anyBracketRange("( [x] )", 3, false)).toEqual({ start: 3, end: 3 }); + }); + + it("supports around", () => { + expect(anyBracketRange("( [x] )", 3, true)).toEqual({ start: 2, end: 4 }); + }); + + it("excludes angle brackets (ib is ()/{}/[] only)", () => { + expect(anyBracketRange("", 1, false)).toBeNull(); + }); + + it("returns null when no bracket encloses the cursor", () => { + expect(anyBracketRange("abc", 1, false)).toBeNull(); + }); +}); diff --git a/test/vim/textobject.test.ts b/test/vim/textobject.test.ts new file mode 100644 index 0000000..a55d4fd --- /dev/null +++ b/test/vim/textobject.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { resolveTextObject } from "../../src/vim/textobject"; + +// resolveTextObject is the single dispatch seam: object char -> range. +// Word (`w`), quote (" ' ` and `q`=any), and bracket (( ) { } [ ] < > and +// `b`=any) objects all route through here; unknown chars return null so +// handlers no-op. + +describe("resolveTextObject", () => { + it("resolves `w` to the inner word range", () => { + expect(resolveTextObject("hello world", 2, "w", false)).toEqual({ start: 0, end: 4 }); + }); + + it("resolves `w` with around to include trailing whitespace", () => { + expect(resolveTextObject("hello world", 2, "w", true)).toEqual({ start: 0, end: 5 }); + }); + + it("returns null for an unknown object char", () => { + expect(resolveTextObject("hello world", 2, "z", false)).toBeNull(); + }); + + it("returns null when the underlying object has no range", () => { + expect(resolveTextObject("", 0, "w", false)).toBeNull(); + }); + + it("resolves double quotes", () => { + expect(resolveTextObject('say "hi"', 6, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("resolves single quotes", () => { + expect(resolveTextObject("say 'hi'", 6, "'", false)).toEqual({ start: 5, end: 6 }); + }); + + it("resolves backticks", () => { + expect(resolveTextObject("say `hi`", 6, "`", false)).toEqual({ start: 5, end: 6 }); + }); + + it("resolves `q` to the nearest quote of any type", () => { + expect(resolveTextObject("'hi'", 2, "q", false)).toEqual({ start: 1, end: 2 }); + }); + + it("resolves parens from either delimiter", () => { + expect(resolveTextObject("(abc)", 2, "(", false)).toEqual({ start: 1, end: 3 }); + expect(resolveTextObject("(abc)", 2, ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("resolves braces, square brackets, and angle brackets", () => { + expect(resolveTextObject("{x}", 1, "{", false)).toEqual({ start: 1, end: 1 }); + expect(resolveTextObject("[x]", 1, "[", false)).toEqual({ start: 1, end: 1 }); + expect(resolveTextObject("", 1, "<", false)).toEqual({ start: 1, end: 1 }); + }); + + it("resolves `b` to the nearest bracket of any type", () => { + expect(resolveTextObject("[x]", 1, "b", false)).toEqual({ start: 1, end: 1 }); + }); + + it("`b` excludes angle brackets", () => { + expect(resolveTextObject("", 1, "b", false)).toBeNull(); + }); + + it("passes the around flag through to pairs", () => { + expect(resolveTextObject("(abc)", 2, "(", true)).toEqual({ start: 0, end: 4 }); + }); +}); diff --git a/test/vim/util.test.ts b/test/vim/util.test.ts new file mode 100644 index 0000000..2352e7a --- /dev/null +++ b/test/vim/util.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { translateKey } from "../../src/vim"; +import { ev } from "../support"; + +// ── translateKey ──────────────────────────────────────────── + +describe("translateKey", () => { + it("lowercase passes through", () => { + expect(translateKey(ev("h"))).toBe("h"); + }); + + it("shift+letter uppercases", () => { + expect(translateKey(ev("g", { shift: true }))).toBe("G"); + }); + + it("shift+4 → $", () => { + expect(translateKey(ev("4", { shift: true }))).toBe("$"); + }); + + it("shift+6 → ^", () => { + expect(translateKey(ev("6", { shift: true }))).toBe("^"); + }); + + it("shift+[ → {", () => { + expect(translateKey(ev("[", { shift: true }))).toBe("{"); + }); + + it("shift+] → }", () => { + expect(translateKey(ev("]", { shift: true }))).toBe("}"); + }); +}); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts new file mode 100644 index 0000000..1dd820d --- /dev/null +++ b/test/vim/visual.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, handleVisualKey, type PromptAccess, type VimState } from "../../src/vim"; +import { mockPrompt } from "../fixtures"; +import { cmds, cursorTos, ev, selectRanges } from "../support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── handleVisualKey — motions ────────────────────────────── + +describe("handleVisualKey — motions", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + it("h dispatches input.select.left", () => { + const r = handleVisualKey(state, "h", ev("h")); + expect(cmds(r.actions)).toEqual(["input.select.left"]); + }); + + it("l dispatches input.select.right", () => { + const r = handleVisualKey(state, "l", ev("l")); + expect(cmds(r.actions)).toEqual(["input.select.right"]); + }); + + it("j dispatches input.select.down", () => { + const r = handleVisualKey(state, "j", ev("j")); + expect(cmds(r.actions)).toEqual(["input.select.down"]); + }); + + it("k dispatches input.select.up", () => { + const r = handleVisualKey(state, "k", ev("k")); + expect(cmds(r.actions)).toEqual(["input.select.up"]); + }); + + it("w dispatches input.select.word.forward", () => { + const r = handleVisualKey(state, "w", ev("w")); + expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); + }); + + it("$ dispatches input.select.line.end", () => { + const r = handleVisualKey(state, "$", ev("4", { shift: true })); + expect(cmds(r.actions)).toEqual(["input.select.line.end"]); + }); + + it("3l dispatches input.select.right 3 times", () => { + handleVisualKey(state, "3", ev("3")); + const r = handleVisualKey(state, "l", ev("l")); + expect(cmds(r.actions)).toEqual(["input.select.right", "input.select.right", "input.select.right"]); + }); + + it("G dispatches input.select.buffer.end", () => { + const r = handleVisualKey(state, "G", ev("g", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.select.buffer.end"]); + }); + + it("e selects from visual anchor to end of word", () => { + // "hello world" with cursor at 0, anchor at 0 → end of "hello" is offset 4 + state.visualAnchor = 0; + const r = handleVisualKey(state, "e", ev("e"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + }); + + it("2e selects from visual anchor to end of 2nd word", () => { + // "hello world" with cursor at 0, anchor at 0 → end of "world" is offset 10 + state.visualAnchor = 0; + handleVisualKey(state, "2", ev("2"), mockPrompt); + const r = handleVisualKey(state, "e", ev("e"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 10 }]); + }); + + it("e pressed twice extends selection to successive word ends", () => { + // "hello world" cursor at 0, anchor at 0 + // First e → end of "hello" (offset 4), must also emit cursorTo + // Second e (cursor now at 4) → end of "world" (offset 10) + state.visualAnchor = 0; + let cursorPos = 0; + const prompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => cursorPos }; + + const r1 = handleVisualKey(state, "e", ev("e"), prompt); + expect(selectRanges(r1.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursorTos(r1.actions)).toEqual([4]); + + // Simulate effect layer applying the cursorTo action + cursorPos = cursorTos(r1.actions)[0]; + + const r2 = handleVisualKey(state, "e", ev("e"), prompt); + expect(selectRanges(r2.actions)).toEqual([{ start: 0, end: 10 }]); + expect(cursorTos(r2.actions)).toEqual([10]); + }); + + it("g sets pending goto, no actions", () => { + const r = handleVisualKey(state, "g", ev("g")); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "goto" }); + }); + + it("gg selects to buffer home", () => { + handleVisualKey(state, "g", ev("g")); + const r = handleVisualKey(state, "g", ev("g")); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toEqual(["input.select.buffer.home"]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("g then Escape in visual cancels pending, stays visual", () => { + handleVisualKey(state, "g", ev("g")); + handleVisualKey(state, "escape", ev("escape")); + expect(state.pending).toEqual({ kind: "none" }); + // escape also exits visual mode + expect(state.mode).toBe("normal"); + }); +}); + +// ── handleVisualKey — operators ──────────────────────────── + +describe("handleVisualKey — operators", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + it("d deletes selection and enters normal mode", () => { + const r = handleVisualKey(state, "d", ev("d")); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("c deletes selection and enters insert mode", () => { + const r = handleVisualKey(state, "c", ev("c")); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("insert"); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("y yanks selection and enters normal mode", () => { + const r = handleVisualKey(state, "y", ev("y")); + expect(r.consume).toBe(true); + expect(r.actions).toContainEqual({ type: "yankSelection" }); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("x deletes selection (alias for d)", () => { + const r = handleVisualKey(state, "x", ev("x")); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("normal"); + }); +}); + +// ── handleVisualKey — exit and passthrough ───────────────── + +describe("handleVisualKey — exit and passthrough", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + it("Escape exits visual mode and clears selection", () => { + const r = handleVisualKey(state, "escape", ev("escape")); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "clearSelection" }); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("v exits visual mode and clears selection", () => { + const r = handleVisualKey(state, "v", ev("v")); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "clearSelection" }); + }); + + it("meta combo passes through", () => { + const r = handleVisualKey(state, "c", ev("c", { meta: true })); + expect(r.consume).toBe(false); + }); + + it("ctrl combo passes through", () => { + const r = handleVisualKey(state, "x", ev("x", { ctrl: true })); + expect(r.consume).toBe(false); + }); + + it("unrecognized key is consumed (no typing in visual)", () => { + const r = handleVisualKey(state, "z", ev("z")); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + }); +}); + +// ── handleVisualKey — arrow keys pass through (issue #63) ── + +describe("handleVisualKey — arrow keys pass through", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + // Arrows are host navigation, not selection motions. Consuming them would + // trap the user the same way normal mode did before issue #63. + for (const arrow of ["up", "down", "left", "right"] as const) { + it(`${arrow} passes through to the host without consuming`, () => { + const r = handleVisualKey(state, arrow, ev(arrow), mockPrompt); + expect(r.consume).toBe(false); + expect(r.actions).toEqual([]); + }); + } +}); + +// ── handleVisualKey — text objects ───────────────────────── + +describe("handleVisualKey — text objects", () => { + // cursor sits inside "hello" of "hello world\n..." + const wordPrompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => 2 }; + + beforeEach(() => { + state.mode = "visual"; + state.visualAnchor = 2; + }); + + it("i sets a pending inner text object, no op", () => { + const r = handleVisualKey(state, "i", ev("i"), wordPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "textobject", around: false }); + }); + + it("viw selects the inner word and anchors to its start", () => { + handleVisualKey(state, "i", ev("i"), wordPrompt); + const r = handleVisualKey(state, "w", ev("w"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursorTos(r.actions)).toEqual([4]); + expect(state.visualAnchor).toBe(0); + }); + + it("vaw selects the word and its trailing whitespace", () => { + handleVisualKey(state, "a", ev("a"), wordPrompt); + const r = handleVisualKey(state, "w", ev("w"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + expect(cursorTos(r.actions)).toEqual([5]); + }); + + it("an unresolved object char cancels without selecting", () => { + handleVisualKey(state, "i", ev("i"), wordPrompt); + const r = handleVisualKey(state, "z", ev("z"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + }); +}); + +// ── handleVisualKey — pair text objects ──────────────────── + +describe("handleVisualKey — pair text objects", () => { + const prompt = (text: string, offset: number): PromptAccess => ({ + ...mockPrompt, + getCursorOffset: () => offset, + getPlainText: () => text, + }); + + beforeEach(() => { + state.mode = "visual"; + }); + + it('vi" selects inside double quotes', () => { + const p = prompt('say "hi"', 6); + state.visualAnchor = 6; + handleVisualKey(state, "i", ev("i"), p); + const r = handleVisualKey(state, '"', ev('"'), p); + expect(selectRanges(r.actions)).toEqual([{ start: 5, end: 6 }]); + expect(cursorTos(r.actions)).toEqual([6]); + expect(state.visualAnchor).toBe(5); + }); + + it("vib selects inside the nearest bracket", () => { + const p = prompt("(abc)", 2); + state.visualAnchor = 2; + handleVisualKey(state, "i", ev("i"), p); + const r = handleVisualKey(state, "b", ev("b"), p); + expect(selectRanges(r.actions)).toEqual([{ start: 1, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); + }); +}); 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";