diff --git a/AGENTS.md b/AGENTS.md index af923f1..6ddc74d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i - **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/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 them only in `devDependencies` (via `@opencode-ai/plugin` which pulls them in for type-checking). +- **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 @@ -58,9 +58,9 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (474 lines) Dual v1 tui/v2 setup entry: intercept registration, action application + index.ts (477 lines) Dual v1 tui/v2 setup entry: intercept registration, action application editor.ts (28 lines) Host-coordinate horizontal selection bounds, preserving native anchor - v2.ts (243 lines) Experimental v2 TUI facade (host input, commands, state, events) + v2.ts (246 lines) Experimental v2 TUI facade (host input, commands, state, events) vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess @@ -88,7 +88,8 @@ test/ textobject.test.ts (64) resolveTextObject dispatch seam integration.test.ts (662) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking editor.test.ts (118) Host-coordinate boundaries and opaque selection-color forwarding - v2.test.ts (369) Experimental v2 facade contract and lifecycle tests + 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 leader.test.ts (125 lines) Unit tests for leader key matching functions compat/ Optional real-host Python driver and test-only TUI fixture (not packaged) ``` @@ -168,7 +169,7 @@ just compat-unit # Pure checks for the optional host harness just compat v1 /absolute/opencode 1.18.33 /canonical/empty/output # Isolated installed-artifact check ``` -The `dev-tui.json` config is picked up only by `just dev`. Running `opencode` normally in this directory does not load the plugin. `just dev2` runs `scripts/dev2.ts` with the tested v2 package (or an explicit binary), loads local source through the root `tui.ts` shim, and isolates on-disk settings/history under `.dev2/`. The shim/launcher are not distributed; package installs still resolve `exports["./tui"]`. Visual mode captures the focused editor on entry and re-anchors at the new editor's cursor on the next eligible key after a prompt switch; overlay keys do not change ownership. Horizontal normalization probes `editBuffer.getTextRange()` in host display-cell coordinates and uses lower `editorView` selection calls to retain the renderer's native anchor. The v2 disabled setting is per activation: external storage reconciliation applies on reload, while local `/vim` updates both controller and form guard immediately. `just compat-unit` runs in CI; live-host checks remain optional. +The `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; live-host checks remain optional. The experimental v2 adapter uses a raw renderer key listener because v2's public keymap does not expose intercepts; see `docs/opencode-v2-poc.md` for configuration and `docs/opencode-v2-strategy.md` for tested alternatives and remaining compatibility gaps. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffaab6..5c54670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +## [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 @@ -369,7 +381,8 @@ 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/oribarilan/vimcode/compare/v0.19.0...HEAD +[Unreleased]: https://github.com/oribarilan/vimcode/compare/v0.19.1...HEAD +[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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f4401e..cee1613 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Add your change ## Host support policy -OpenCode v2 support is rolling out gradually and is experimental in vimcode v0.19.0. 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. +OpenCode v2 support is rolling out gradually and remains experimental in vimcode v0.19.1. 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 @@ -83,19 +83,19 @@ Releases are manual. 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.0` still includes v1 compatibility for users who opt in: +On OpenCode **v1**, use `tui.json`. The recommended legacy pin is `v0.18.1`; `v0.19.1` includes v1 compatibility and the subagent-navigation fix 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.0`. v2 support is experimental; the v1-only `v0.18.1` release does not work on v2: +On OpenCode **v2**, use `cli.json` and pin `v0.19.1`. v2 support is experimental; the v1-only `v0.18.1` release does not work on v2: ```json { "plugins": [ { - "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.0" + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.1" } ] } diff --git a/README.md b/README.md index 0ed2dfe..739e016 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ OpenCode v2 support is rolling out gradually, starting with experimental support | OpenCode version | Recommended vimcode pin | Status | |------------------|-------------------------|--------| -| v1 | `v0.18.1` | Previous v1-only release. `v0.19.0` also includes v1 compatibility during the transition. | -| v2 | `v0.19.0` | Experimental; validated on OpenCode 2.0.15 on macOS. | +| v1 | `v0.18.1` | Previous v1-only release. Opt into `v0.19.1` for the subagent-navigation fix and shared v1/v2 updates. | +| v2 | `v0.19.1` | Experimental; validated on OpenCode 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. @@ -38,7 +38,7 @@ Use the instructions for your OpenCode major version. Both versions use the same ### OpenCode v1 -We recommend pinning `v0.18.1` for existing v1 users who want the previous v1-only behavior. To opt into the shared fixes in `v0.19.0`, change the Git ref to `v0.19.0`; revert to the older pin if you encounter a regression. +We recommend pinning `v0.18.1` for existing v1 users who want the previous v1-only behavior. To get the subagent-navigation fix and shared updates in `v0.19.1`, change the Git ref to `v0.19.1`; revert to the older pin if you encounter a regression. Add to your `tui.json` (or `.opencode/tui.json`): @@ -54,13 +54,13 @@ By default, you'll see a toast when a newer vimcode version is available. If you ### OpenCode v2 (experimental) -Pin `v0.19.0` in your global `cli.json`. The older `v0.18.1` release does **not** support v2. +Pin `v0.19.1` in your global `cli.json`. The older `v0.18.1` release does **not** support v2. ```json { "plugins": [ { - "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.0" + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.1" } ] } @@ -88,7 +88,7 @@ Put `options` alongside `package` in `cli.json`: { "plugins": [ { - "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.0", + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.1", "options": { "updateCheck": false } } ] @@ -116,6 +116,10 @@ In normal mode, keys are vim commands. Unrecognized keys get swallowed so you do 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. +### 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 First Escape in insert mode switches to normal - it won't trigger OpenCode's double-escape interrupt. So canceling a running response from insert mode takes 3 escapes: one for normal, two more for the interrupt. @@ -145,7 +149,7 @@ On **v2**, set both the host keybind and the plugin option in `cli.json`: "keybinds": { "leader": "space" }, "plugins": [ { - "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.0", + "package": "vimcode@git+https://github.com/oribarilan/vimcode.git#v0.19.1", "options": { "experimentalV2Leader": "space" } } ] 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 index 4bbaa7f..0b3f417 100644 --- a/docs/opencode-v2-poc.md +++ b/docs/opencode-v2-poc.md @@ -1,11 +1,13 @@ # Experimental OpenCode v2 POC -These notes record the v2 proof of concept and compatibility evidence. v2 support is experimental in vimcode v0.19.0; 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. +These notes record the v2 proof of concept and compatibility evidence. v2 support is experimental in vimcode v0.19.1; 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: @@ -21,7 +23,7 @@ The root `tui.ts` re-exports `src/index.ts` for v2's local-directory loader. Thi The v2 public `keymap` has no key intercept or configured-leader lookup. The POC uses `context.renderer.keyInput.prependListener("keypress", ...)` to intercept prompt keys **before** the host keymap. It calls both `preventDefault()` and `stopPropagation()` on consumed keys. This raw OpenTUI ordering is not a documented OpenCode plugin guarantee; do not rely on it as production compatibility without testing against installed packages and future host releases. -The [README](../README.md#opencode-v2-experimental) shows the release Git install pinned to `v0.19.0`. The earlier Git experiment below used commit `d8050f765b6c2c4e7fdc700d8345c1c5752644cb`. Use the global `cli.json` on v2, not v1's `tui.json`. +The [README](../README.md#opencode-v2-experimental) shows the release Git install pinned to `v0.19.1`. 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`: @@ -29,7 +31,7 @@ To test local changes instead, create a fresh artifact directory and build a tar { "plugins": [ { - "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.19.0.tgz", + "package": "vimcode@file:/absolute/path/to/artifacts/vimcode-0.19.1.tgz", "options": { "updateCheck": false, "experimentalV2Leader": "ctrl+x" } } ] @@ -58,6 +60,14 @@ The same tarball was exercised in real macOS tmux sessions on OpenCode **2.0.15* 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. diff --git a/package.json b/package.json index f48808f..8626605 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vimcode", - "version": "0.19.0", + "version": "0.19.1", "description": "Vim keybindings for the OpenCode prompt", "author": "Ori Bar-ilan", "license": "MIT", @@ -30,6 +30,7 @@ "devDependencies": { "@biomejs/biome": "2.4.15", "@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 index 9a777dd..7077ea4 100644 --- a/scripts/dev2.ts +++ b/scripts/dev2.ts @@ -22,7 +22,12 @@ Object.assign(env, { 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" }, + keybinds: { + leader: "ctrl+x", + "session.child.first": ["down", "down", "j"], + "composer.subagent.up": ["up", "h", "k"], + "composer.subagent.down": ["down", "j", "l"], + }, }), }); diff --git a/src/index.ts b/src/index.ts index 19142c5..9439cb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -353,6 +353,9 @@ const plugin = { const route = api.route.current; if (route.name === "session") { const sid = route.params?.sessionID; + // Child sessions have no editable prompt. Let host navigation own + // the keys without changing the mode restored in the parent (#79). + if (typeof sid === "string" && api.state?.session?.get?.(sid)?.parentID) return; if (sid && hasActivePrompts(sid)) { // Consume the leader key so dispatchLayers() doesn't // match it as a leader token, which would enter pending- diff --git a/src/v2.ts b/src/v2.ts index 62b2609..56252df 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -199,7 +199,10 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose }, state: { session: { - get: (sessionID: string) => ({ parentID: context.data.session.root(sessionID) }), + 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) => diff --git a/src/version.ts b/src/version.ts index 7fee039..6da052e 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.19.0"; +export const VERSION = "0.19.1"; // GitHub API returns fresh content immediately; raw.githubusercontent.com // is CDN-cached for up to 5 minutes which delays update detection. 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/v2.test.ts b/test/v2.test.ts index fd436d4..8b67829 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import plugin from "../src/index"; -import type { V2Context } from "../src/v2"; +import { createV2Facade, type V2Context } from "../src/v2"; type Press = { name: string; ctrl?: boolean; shift?: boolean; eventType?: string }; type RawPressEvent = Press & { preventDefault(): void; stopPropagation(): void }; @@ -184,6 +184,65 @@ describe("OpenCode v2 POC facade", () => { 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);