opencode-vim-navigation 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,66 +1,79 @@
1
1
  # opencode-vim-navigation
2
2
 
3
3
  Modal Vim-style transcript navigation for the **OpenCode terminal UI**.
4
- Scroll the conversation inside OpenCode itself—not your terminal emulator's,
5
- Neovim's, or tmux's scrollback—while keeping your prompt draft safe.
4
+ Scroll the conversation inside OpenCode—not Neovim, tmux, or terminal-emulator
5
+ scrollback—while keeping your prompt draft safe.
6
6
 
7
7
  Press **Ctrl+Q** to switch between INPUT and NAV. A compact, right-aligned
8
- mode label appears on a divider just above the prompt.
8
+ mode label appears on a divider immediately above the prompt.
9
+
10
+ When the native plan-review form is the only pending form, Ctrl+Q also enters
11
+ NAV even though the prompt does not own focus. Navigation scrolls the session
12
+ transcript behind the form; NAV consumes Enter, Escape, and all other
13
+ non-navigation keys so they cannot approve, cancel, or edit the form. Press
14
+ Ctrl+Q again to return to the form. Permission prompts and unrelated forms
15
+ continue to own their keys and block NAV.
9
16
 
10
17
  ## Compatibility
11
18
 
12
- Tested with **OpenCode 2.1.4**. This uses OpenCode V2's TUI plugin API and is
13
- not a server plugin or a plugin for the desktop/web UI. Older releases and
14
- future API changes may require adaptation.
19
+ Tested with **OpenCode 2.1.4**. This uses OpenCode V2's TUI plugin API, not the
20
+ server plugin API or the desktop/web UI. Future host API changes may require
21
+ adaptation, even if the peer dependency ranges still match.
22
+
23
+ OpenCode supplies the plugin API, OpenTUI, and Solid runtime instances. The
24
+ adapter is native JavaScript without JSX: no build step or React dependency
25
+ is needed. Do not run the OpenCode entrypoint directly with Node.js.
26
+
27
+ ## Install from npm
15
28
 
16
- No build step is needed: OpenCode supplies `@opencode/plugin/tui`, OpenTUI,
17
- and `solid-js`. The adapter is plain JavaScript and constructs OpenTUI elements
18
- without JSX, avoiding different JSX transforms for local and npm installs.
19
- OpenTUI and Solid are declared
20
- as peer dependencies so the package documents its host compatibility.
21
- Do not run this entrypoint directly with Node.js.
29
+ Add the package to `~/.config/opencode/cli.json`:
22
30
 
23
- ## Install
31
+ ```json
32
+ {
33
+ "plugins": ["opencode-vim-navigation"]
34
+ }
35
+ ```
24
36
 
25
- ### From a local checkout (available now)
37
+ Merge this with your existing settings and restart OpenCode. This belongs in
38
+ **`cli.json`**, not `opencode.json` or the V1-era `tui.json`.
26
39
 
27
- Clone the repository:
40
+ For reproducible installations, check the available release and pin that
41
+ version instead of using the bare package name:
28
42
 
29
43
  ```sh
30
- git clone https://github.com/Raaghav-Arya/opencode-vim-navigation.git
44
+ npm view opencode-vim-navigation version
31
45
  ```
32
46
 
33
- Then add its **absolute directory path** to the `plugins` array in
34
- `~/.config/opencode/cli.json`:
47
+ Use `opencode-vim-navigation@<published-version>` in the plugin array. Do not
48
+ assume that an unpinned entry automatically updates an existing installation.
49
+ Use OpenCode's plugin update workflow when upgrading.
35
50
 
36
- ```json
37
- {
38
- "plugins": ["/absolute/path/to/opencode-vim-navigation"]
39
- }
40
- ```
51
+ **Avoid versions 0.1.0 and 0.1.1:** they contain dependency/JSX packaging errors.
52
+ Version 0.1.2 fixed loading. Source version **0.1.3** additionally hardens
53
+ permission handling, prompt identity checks, and setup rollback. Check npm
54
+ availability before pinning 0.1.3; use a local checkout to test changes that
55
+ have not been published yet.
41
56
 
42
- Merge this into any existing TUI config; do not overwrite other settings.
43
- Restart OpenCode. No `npm install` is needed for this local package.
57
+ [npm package](https://www.npmjs.com/package/opencode-vim-navigation) ·
58
+ [GitHub](https://github.com/Raaghav-Arya/opencode-vim-navigation)
44
59
 
45
- Alternatively, symlink the package directory into
46
- `~/.config/opencode/plugins/opencode-vim-navigation` for automatic discovery.
47
- Use **one installation method**, not both.
60
+ ### Install a local checkout
48
61
 
49
- ### From npm (after publication)
62
+ ```sh
63
+ git clone https://github.com/Raaghav-Arya/opencode-vim-navigation.git
64
+ ```
50
65
 
51
- Once the package has been published as `opencode-vim-navigation`, use:
66
+ Then use its **absolute directory path** in `cli.json`:
52
67
 
53
68
  ```json
54
69
  {
55
- "plugins": ["opencode-vim-navigation@0.1.0"]
70
+ "plugins": ["/absolute/path/to/opencode-vim-navigation"]
56
71
  }
57
72
  ```
58
73
 
59
- This belongs in **`cli.json`**, not the server plugin list in `opencode.json`.
60
- OpenCode V1-era documentation may mention `tui.json`; these instructions target V2.
61
- OpenCode installs the package. Pin a version for reproducible configuration.
62
- Until the first npm release, use the local-checkout installation above.
63
- Check [npm](https://www.npmjs.com/package/opencode-vim-navigation) for availability.
74
+ No `npm install` is needed for this local package. Alternatively, symlink its
75
+ directory into `~/.config/opencode/plugins/opencode-vim-navigation` for automatic
76
+ discovery. Use **one installation method**, not both. Restart OpenCode.
64
77
 
65
78
  ## Keys
66
79
 
@@ -75,23 +88,24 @@ Check [npm](https://www.npmjs.com/package/opencode-vim-navigation) for availabil
75
88
  | **i** | Type normally | Return to INPUT, follow latest output, preserve draft |
76
89
  | **Escape / Ctrl+C** | OpenCode default | Leave NAV without moving the viewport, then perform OpenCode's normal interrupt/clear action |
77
90
 
78
- Only Ctrl+Q is intercepted in INPUT. In particular, Ctrl+U/D retain their
79
- normal prompt-editing/exit behavior until you enter NAV.
80
-
81
- In NAV, other keys, Enter, and pasted text are ignored to protect your draft
82
- and prevent accidental submissions. This is **transcript navigation**, not
83
- Vim editing, cursor selection, or visual mode.
91
+ Only Ctrl+Q is intercepted in INPUT, and only when the actual normal prompt
92
+ is focused. Ctrl+U/D keep their default prompt-editing/exit behavior until NAV.
93
+ In NAV, other keys, Enter, and pasted text are ignored to protect the draft
94
+ and avoid accidental submissions. This is transcript navigation, not Vim
95
+ editing, selection, or visual mode.
84
96
 
85
- Dialogs, autocomplete, composer panels, and other non-base modes keep their
86
- own keyboard handling. Incoming permission/form requests and session changes
87
- exit NAV. Existing mouse behavior is unchanged. The home screen is unchanged.
97
+ Dialogs and other non-base modes keep their own keyboard handling. NAV is
98
+ not entered from shell mode, autocomplete, unrelated textareas, or permission
99
+ rejection editors. Pending permissions/forms prevent NAV entry; incoming
100
+ requests exit NAV. Root-session checks include descendant/subagent requests,
101
+ and location-scoped global forms are checked as well. Session changes reset
102
+ NAV. The home screen and mouse behavior are unchanged.
88
103
 
89
104
  ### Neovim / Sidekick
90
105
 
91
106
  Stay in Neovim **terminal mode**. OpenCode draws its own transcript, so normal
92
- terminal-buffer scrollback cannot reliably navigate the conversation.
93
-
94
- Let OpenCode receive Ctrl+Q and handle scrolling:
107
+ terminal-buffer scrollback cannot reliably navigate it. Let OpenCode receive
108
+ Ctrl+Q and handle scrolling:
95
109
 
96
110
  ```lua
97
111
  cli = {
@@ -104,160 +118,159 @@ cli = {
104
118
  }
105
119
  ```
106
120
 
107
- Merge these options into your existing Sidekick configuration. Terminal or
108
- multiplexer mappings that intercept these keys must also be disabled.
121
+ Merge these options into your existing configuration. Disable terminal or
122
+ multiplexer mappings that intercept the same keys.
109
123
 
110
- ## Development
124
+ ## Development and verification
111
125
 
112
- Use Node.js 22 or newer for the development commands:
126
+ Use Node.js 22 or newer:
113
127
 
114
128
  ```sh
115
129
  npm test
116
130
  npm run check
117
- npm pack --dry-run
131
+ npm run test:package
118
132
  ```
119
133
 
120
- There are no development dependencies to install. `npm run check` checks the
121
- JavaScript controller, TUI adapter, and tests. OpenCode loads the small
122
- TypeScript entrypoint; the adapter itself is native JavaScript, without JSX.
134
+ - **Unit/adapter tests:** use the real controller and adapter with an injected,
135
+ mocked host. Cover input consumption, draft/submission spies, request
136
+ aggregation, focus identity, modal stacking, cleanup, and failure rollback.
137
+ These tests are not a real OpenCode renderer or Solid reactivity test.
138
+ - **Syntax checks:** parse all runtime JavaScript and test files. There is no
139
+ TSX compilation stage; this is not a TypeScript typecheck.
140
+ - **Package test:** packs the real archive, checks its contents, installs it
141
+ into a temporary consumer with normal peer resolution and scripts disabled,
142
+ and verifies installed source syntax/relative imports. Requires npm registry
143
+ access. It does not load OpenCode's host-provided runtime modules.
144
+
145
+ The GitHub workflow runs all three checks. `prepublishOnly` also runs all
146
+ three before publication. Package tests leave no dependencies or archives in
147
+ the working checkout.
123
148
 
124
149
  ```text
125
150
  tui.ts Conventional local/package entrypoint
126
151
  src/
152
+ tui.mjs Host imports and plugin definition
153
+ adapter.mjs Host integration, request/focus guards, mode indicator
127
154
  navigation.mjs Mode state and input routing
128
- tui.mjs OpenCode adapter and mode indicator (no JSX)
129
155
  tests/
130
- navigation.test.mjs Controller tests using node:test
156
+ navigation.test.mjs Controller tests
157
+ adapter.test.mjs Adapter regression tests with mocked host
158
+ scripts/
159
+ test-package.mjs Packed consumer installation checks
131
160
  ```
132
161
 
133
- The 13 tests mock the host and cover toggling, navigation, INPUT passthrough,
134
- paste/draft protection, dialog isolation, route cleanup, and missing commands.
135
- They do not replace interactive rendering tests.
136
-
137
- The initial implementation also passed a live pseudo-terminal smoke test
138
- against OpenCode 2.1.4: INPUT/NAV switched, navigation changed the viewport,
139
- and returning to INPUT preserved the draft, without submitting any prompt.
140
- The 0.1.2 packaging fix was additionally tested through OpenCode's **npm
141
- package loader**, using a temporary local registry serving the packed archive:
142
- installation, INPUT/NAV switching, scrolling, and draft preservation passed.
143
- This exercises the package-loading path rather than a source-directory install.
144
- Recheck the same behavior after changing the adapter or upgrading OpenCode.
145
-
146
- ### Implementation details
162
+ ### Manual OpenCode runtime check
163
+
164
+ Test both a checkout **and the packed/published package**, since OpenCode's
165
+ local and npm loaders differ. The earlier JSX regression only occurred in
166
+ the npm loading path. Validate:
167
+
168
+ 1. The installed package loads without plugin errors; INPUT is visible.
169
+ 2. Enter a draft without submitting it. Ctrl+Q enters NAV.
170
+ 3. j/k, gg/G, Ctrl+U/D visibly change the transcript viewport.
171
+ 4. Unknown keys, Enter, and bracketed paste in NAV do not alter the draft or
172
+ submit a prompt. Test submission-sensitive input only in an isolated session
173
+ without model access; do not risk sending an actual user's draft.
174
+ 5. `i` returns to INPUT with the **exact final draft** intact, not merely draft
175
+ text appearing somewhere in a terminal recording.
176
+ 6. Ctrl+U/D behave normally in INPUT.
177
+ 7. Existing/new permission requests, including subagent requests, remain
178
+ interactive. Ctrl+Q does not capture their rejection-reason editor.
179
+ 8. Dialogs, forms, shell mode, session switches, and plugin disable/re-enable
180
+ retain correct input ownership, with no duplicate listeners.
181
+
182
+ A load/keyboard smoke test against the public 0.1.2 package passed. The packed
183
+ 0.1.3 archive also passed a live OpenCode npm-loader smoke test using a
184
+ temporary local registry and isolated package cache. These checks verify
185
+ loading, mode indicator changes, and keyboard-driven rendering, not every
186
+ permission interaction or a strict no-submission guarantee. Earlier
187
+ smoke assertions based only on terminal byte counts or a draft substring
188
+ were insufficient to prove exact draft preservation or absence of submission.
189
+ Do not describe those checks as a complete end-to-end guarantee.
190
+
191
+ ### Implementation notes
147
192
 
148
193
  The root `tui.ts` re-exports `src/tui.mjs`. OpenCode 2.1.4's local-directory
149
- loader looks for a conventional root `tui` entrypoint; npm packages also
150
- expose it via the `./tui` package export. Keep both when reorganizing source.
151
-
152
- See the [OpenCode V2 CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli)
153
- for the host API and package conventions.
194
+ loader expects a conventional root `tui` entrypoint; npm packages expose it
195
+ through `./tui`. Keep both when reorganizing source.
154
196
 
155
- The plugin uses OpenTUI's public prepended key/paste listeners to stop NAV
156
- input before it reaches the prompt. A named OpenCode keymap mode isolates
157
- normal prompt shortcuts. Host transcript commands scroll the actual
158
- conversation, including virtualized history/backfill; the plugin does not
159
- scrape a terminal buffer. Listeners, mode, effects, and the mode indicator
160
- are cleaned up when the plugin deactivates.
197
+ Prepended OpenTUI key/paste listeners stop NAV input before the editor.
198
+ OpenCode keymap modes isolate prompt shortcuts, while host transcript
199
+ commands handle scrolling and virtualized history/backfill. The adapter uses
200
+ OpenCode's prompt traits to distinguish the main prompt from other textareas.
201
+ Blocking requests are checked reactively and again at event dispatch to avoid
202
+ races with incoming permissions. Acquired resources are unwound on setup
203
+ failure as well as normal plugin teardown.
161
204
 
162
205
  In **2.1.4**, `session.page.up/down` move by half of `scroll.height`, whereas
163
- `session.half.page.up/down` move by a quarter. The plugin deliberately uses
164
- the former. It checks for expected commands before entering NAV, but future
165
- releases could retain names while changing semantics.
206
+ `session.half.page.up/down` move by a quarter. This plugin deliberately uses
207
+ the former. Required-command checks cannot detect future semantic changes.
208
+
209
+ See the [OpenCode V2 CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli)
210
+ for host APIs and package conventions.
166
211
 
167
212
  ## Disable / uninstall
168
213
 
169
- Remove the package entry from `cli.json`, then restart OpenCode. If installed
170
- via automatic discovery instead, remove the symlink from `plugins/`.
171
- Merely renaming a package directory within `plugins/` does not disable it.
214
+ Remove the package entry from `cli.json`, then restart OpenCode. For automatic
215
+ discovery, remove its symlink from `plugins/`; merely renaming a package
216
+ directory within `plugins/` does not disable it.
172
217
 
173
- The plugin ID in OpenCode's TUI plugin manager is `opencode-vim-navigation`.
218
+ The TUI plugin manager ID is `opencode-vim-navigation`.
174
219
 
175
220
  ## Maintainer: publishing to npm
176
221
 
177
- GitHub hosts the source; npm distributes the installable package. Publishing
178
- to GitHub does not automatically publish to npm. Public unscoped packages
179
- can be published with a free npm account.
222
+ GitHub hosts the source; npm distributes the package. Pushing to GitHub does
223
+ not automatically publish to npm. A free npm account can publish this public,
224
+ unscoped package.
180
225
 
181
- ### First release
226
+ ### Account setup (once)
182
227
 
183
- 1. Create an account at [npmjs.com/signup](https://www.npmjs.com/signup) if
184
- needed, verify your email, and enable two-factor authentication in your
185
- account settings. GitHub CLI authentication does **not** log you into npm.
186
- Save your recovery codes privately; never commit tokens or recovery codes.
187
- 2. From your checkout, log in using the browser flow:
228
+ 1. Create an account at [npmjs.com/signup](https://www.npmjs.com/signup), verify
229
+ your email, and enable two-factor authentication. Keep recovery codes private.
230
+ GitHub CLI authentication does not log you into npm.
231
+ 2. Authenticate using the browser flow:
188
232
 
189
233
  ```sh
190
- npm login --registry=https://registry.npmjs.org/
234
+ npm login --auth-type=web --registry=https://registry.npmjs.org/
191
235
  npm whoami --registry=https://registry.npmjs.org/
192
236
  ```
193
237
 
194
- 3. Check the package name:
238
+ If publication returns E403 requiring 2FA, finish account 2FA setup and refresh
239
+ your login. Complete npm's browser/OTP prompts privately. Do not share tokens,
240
+ authentication codes, or recovery codes in issues or chat.
195
241
 
196
- ```sh
197
- npm view opencode-vim-navigation name version --registry=https://registry.npmjs.org/
198
- ```
242
+ ### Release
199
243
 
200
- An E404 means no public package was found; it is not a reservation or a
201
- guarantee that npm will accept the name. If someone else owns it, choose
202
- a scoped name such as `@YOUR_NPM_USERNAME/opencode-vim-navigation` and
203
- update `package.json` and the installation examples before publishing.
204
- 4. Review the release from the repository root:
244
+ 1. Review the diff, choose a new version, and run checks. A version already
245
+ published to npm cannot be overwritten or reused. If the working checkout
246
+ has already been bumped for the release, **do not bump it again**.
247
+ 2. Review the artifact and publication dry run:
205
248
 
206
249
  ```sh
207
250
  npm test
208
251
  npm run check
252
+ npm run test:package
209
253
  npm pack --dry-run
210
254
  npm publish --dry-run
211
255
  ```
212
256
 
213
- The package should include `LICENSE`, `README.md`, `package.json`, `tui.ts`,
214
- and `src/`. Tests and GitHub workflows stay on GitHub, not in the tarball.
215
- Dry runs do not upload the package and do not prove your account has
216
- permission to publish. The `prepublishOnly` script reruns tests and
217
- JavaScript syntax checks before publication.
218
- 5. Publish version **0.1.0**:
257
+ Tests, scripts, and GitHub workflows stay on GitHub, not in the archive.
258
+ Dry runs do not upload a package or prove permission to publish.
259
+ 3. Commit the reviewed release changes, then publish:
219
260
 
220
261
  ```sh
221
262
  npm publish --access public
222
263
  ```
223
264
 
224
- Complete npm's browser / two-factor prompts. Do not paste authentication
225
- codes or tokens into issues or chat. A successful publish ends with
226
- `+ opencode-vim-navigation@0.1.0`.
227
- 6. Verify the release:
228
-
229
- ```sh
230
- npm view opencode-vim-navigation@0.1.0 version dist.tarball
231
- ```
232
-
233
- Replace the local path in your OpenCode `cli.json` with
234
- `opencode-vim-navigation@0.1.0` and restart OpenCode. Remove the
235
- "after publication" wording in this README once the package is live.
236
- 7. Optionally mark the published source on GitHub:
237
-
238
- ```sh
239
- git tag v0.1.0
240
- git push origin v0.1.0
241
- ```
242
-
243
- Tag the exact commit used for publication; commit any release changes first.
244
-
245
- ### Later releases
246
-
247
- npm versions cannot be overwritten or reused. For a compatible bug fix,
248
- start from a clean checkout, update the changelog/README as appropriate, run
249
- checks, then:
250
-
251
- ```sh
252
- npm version patch
253
- npm publish --access public
254
- git push origin main --follow-tags
255
- ```
265
+ 4. Verify the published version using `npm view opencode-vim-navigation`,
266
+ install that exact version in OpenCode, and run the runtime checklist above.
267
+ 5. Tag the exact published commit (`git tag v<VERSION>`), push the commit and
268
+ tag, then create the matching GitHub release.
256
269
 
257
- `npm version patch` updates the version and creates a commit and tag. Use a
258
- minor or major bump when appropriate instead. Publish manually initially;
259
- if automated releases are added later, prefer npm trusted publishing rather
260
- than storing a long-lived npm token in the repository.
270
+ For later fixes from a clean checkout, `npm version patch` updates the version
271
+ and creates a commit/tag. Use a minor/major bump when appropriate. Do not use
272
+ that command on an already-bumped release. If automating publication later,
273
+ prefer npm trusted publishing over long-lived tokens.
261
274
 
262
275
  ## License
263
276
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vim-navigation",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Modal Vim-style transcript navigation for the OpenCode terminal UI",
5
5
  "license": "MIT",
6
6
  "author": "Raaghav Arya",
@@ -17,11 +17,20 @@
17
17
  "registry": "https://registry.npmjs.org/"
18
18
  },
19
19
  "type": "module",
20
- "keywords": ["opencode", "opencode-plugin", "tui", "vim", "navigation"],
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-plugin",
23
+ "tui",
24
+ "vim",
25
+ "navigation"
26
+ ],
21
27
  "exports": {
22
28
  "./tui": "./tui.ts"
23
29
  },
24
- "files": ["tui.ts", "src/"],
30
+ "files": [
31
+ "tui.ts",
32
+ "src/"
33
+ ],
25
34
  "peerDependencies": {
26
35
  "@opentui/core": ">=0.6.8 <0.7.0",
27
36
  "@opentui/solid": ">=0.6.8 <0.7.0",
@@ -29,7 +38,8 @@
29
38
  },
30
39
  "scripts": {
31
40
  "test": "node --test tests/*.test.mjs",
32
- "check": "node --check src/navigation.mjs && node --check src/tui.mjs && node --check tests/navigation.test.mjs",
33
- "prepublishOnly": "npm test && npm run check"
41
+ "check": "node --check src/navigation.mjs && node --check src/adapter.mjs && node --check src/tui.mjs && node --check tests/navigation.test.mjs && node --check tests/adapter.test.mjs && node --check scripts/test-package.mjs",
42
+ "test:package": "node scripts/test-package.mjs",
43
+ "prepublishOnly": "npm test && npm run check && npm run test:package"
34
44
  }
35
45
  }
@@ -0,0 +1,124 @@
1
+ import { navigation } from "./navigation.mjs"
2
+
3
+ // Inject host-provided rendering/reactivity APIs so tests exercise the real adapter
4
+ // without rewriting its imports or depending on OpenCode's runtime module loader.
5
+ export function createSetup({ InputRenderable, TextareaRenderable, createElement, spread, createEffect, createRoot, createSignal }) {
6
+ return function setup(context) {
7
+ const { renderer, keymap, ui, data } = context
8
+ if (!renderer.keyInput?.prependListener || !renderer.keyInput?.removeListener
9
+ || !keymap.mode?.push || !keymap.mode?.current
10
+ || !data.session?.get || !data.session?.family
11
+ || !data.session?.permission?.list || !data.session?.form?.list) {
12
+ throw new Error("Vim navigation requires the OpenCode 2.1.4 TUI API")
13
+ }
14
+ const [nav, setNav] = createSignal(false)
15
+ const session = () => {
16
+ const route = ui.router.current()
17
+ return route.type === "session" ? route.sessionID : undefined
18
+ }
19
+ const promptFocused = () => {
20
+ const editor = renderer.currentFocusedEditor
21
+ const traits = editor?.traits
22
+ return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
23
+ && traits?.owner === "opencode" && traits?.role === "prompt"
24
+ && !traits.status
25
+ && !traits.capture?.includes("navigate") && !traits.capture?.includes("submit")
26
+ && !traits.capture?.includes("autocomplete")
27
+ }
28
+ const visibleForms = () => {
29
+ const id = session()
30
+ if (!id) return []
31
+ const info = data.session.get(id)
32
+ const location = info?.location ?? context.location
33
+ // Match the host's visible request set: root sessions aggregate their
34
+ // descendants; child sessions show global/location forms only.
35
+ const forms = [...(data.session.form.list("global", location) ?? [])]
36
+ if (info?.parentID) return forms
37
+ const ids = new Set([id, ...data.session.family(id)])
38
+ for (const sessionID of ids) forms.push(...(data.session.form.list(sessionID) ?? []))
39
+ return forms
40
+ }
41
+ const planReviewForm = () => {
42
+ const forms = visibleForms()
43
+ return forms.length > 0 && forms.every((form) => form.metadata?.plugin === "raaghav.plan-review")
44
+ }
45
+ const blocked = () => {
46
+ const id = session()
47
+ if (!id) return false
48
+ const info = data.session.get(id)
49
+ if (info?.parentID) return visibleForms().some((form) => form.metadata?.plugin !== "raaghav.plan-review")
50
+ const location = info?.location ?? context.location
51
+ if (data.session.permission.list(id)?.length) return true
52
+ if (data.session.family(id).some((sessionID) => data.session.permission.list(sessionID)?.length)) return true
53
+ return visibleForms().some((form) => form.metadata?.plugin !== "raaghav.plan-review")
54
+ }
55
+ const required = ["session.line.up", "session.line.down", "session.page.up", "session.page.down", "session.first", "session.last"]
56
+ const controller = navigation({
57
+ session,
58
+ mode: keymap.mode,
59
+ promptFocused,
60
+ blocked,
61
+ planReviewForm,
62
+ ready: () => {
63
+ const commands = new Set(keymap.commands().map((command) => command.id))
64
+ if (required.every((id) => commands.has(id))) return true
65
+ ui.toast.show({ message: "Vim navigation: this OpenCode version lacks the expected scrolling commands", variant: "error" })
66
+ return false
67
+ },
68
+ dispatch: (id) => keymap.dispatch(id),
69
+ changed: setNav,
70
+ })
71
+ const cleanups = [() => controller.leave()]
72
+ let cleaned = false
73
+ const cleanup = () => {
74
+ if (cleaned) return
75
+ cleaned = true
76
+ const errors = []
77
+ for (const release of cleanups.reverse()) {
78
+ try { release() } catch (error) { errors.push(error) }
79
+ }
80
+ if (errors.length) throw new AggregateError(errors, "Vim navigation cleanup failed")
81
+ }
82
+ try {
83
+ // Register rollback BEFORE each acquisition: even a partially failing
84
+ // listener registration must not leave a failed generation intercepting keys.
85
+ cleanups.push(() => renderer.keyInput.removeListener("keypress", controller.key))
86
+ renderer.keyInput.prependListener("keypress", controller.key)
87
+ cleanups.push(() => renderer.keyInput.removeListener("paste", controller.paste))
88
+ renderer.keyInput.prependListener("paste", controller.paste)
89
+ createRoot((dispose) => {
90
+ cleanups.push(dispose)
91
+ createEffect(() => {
92
+ nav()
93
+ session()
94
+ blocked()
95
+ planReviewForm()
96
+ promptFocused()
97
+ controller.sync()
98
+ })
99
+ })
100
+ cleanups.push(ui.slot({
101
+ append: "session.composer.top",
102
+ render: () => {
103
+ const element = createElement("box")
104
+ spread(element, {
105
+ height: 1,
106
+ width: "100%",
107
+ border: ["top"],
108
+ get borderColor() { return context.theme.border.base },
109
+ get title() { return nav() ? " NAV · i: input " : " INPUT · Ctrl+Q: navigate " },
110
+ get titleColor() { return nav() ? context.theme.text.base : context.theme.text.muted },
111
+ titleAlignment: "right",
112
+ })
113
+ return element
114
+ },
115
+ }))
116
+ return cleanup
117
+ } catch (error) {
118
+ try { cleanup() } catch (rollbackError) {
119
+ throw new AggregateError([error, rollbackError], "Vim navigation setup and rollback failed")
120
+ }
121
+ throw error
122
+ }
123
+ }
124
+ }
@@ -6,17 +6,20 @@ export function navigation(context) {
6
6
  let pendingG = false
7
7
 
8
8
  const active = () => popMode !== undefined
9
+ const planReview = () => context.planReviewForm?.() === true
10
+ const navigationAllowed = () => !context.blocked?.() && (context.promptFocused() || planReview())
9
11
  function leave(follow = false) {
10
12
  if (!active()) return
11
- popMode()
13
+ const release = popMode
12
14
  popMode = undefined
13
15
  pendingG = false
14
16
  sessionID = undefined
15
- context.changed(false)
17
+ try { release() } finally { context.changed(false) }
16
18
  if (follow) context.dispatch("session.last")
17
19
  }
18
20
  function sync() {
19
- if (active() && context.session() !== sessionID) leave()
21
+ if (active() && (context.session() !== sessionID || !navigationAllowed()
22
+ || (context.mode.current() === MODE && !context.promptFocused() && !planReview()))) leave()
20
23
  }
21
24
  function scroll(id) {
22
25
  context.dispatch(id)
@@ -27,11 +30,15 @@ export function navigation(context) {
27
30
  }
28
31
  function key(event) {
29
32
  sync()
30
- if (!context.session()) return
33
+ if (!context.session() || !navigationAllowed()) return
31
34
  // Dialogs, permission forms, autocomplete, and embedded terminals own their keys.
32
35
  const mode = context.mode.current()
33
- if (mode !== "base" && mode !== MODE) return
34
- if (!active() && !context.promptFocused()) return
36
+ const isPlanReview = planReview()
37
+ if (mode !== "base" && mode !== MODE && !isPlanReview) return
38
+ if (!context.promptFocused() && !isPlanReview) {
39
+ if (active()) leave()
40
+ return
41
+ }
35
42
  const plain = !event.ctrl && !event.meta && !event.super && !event.hyper
36
43
  const ctrl = event.ctrl && !event.meta && !event.super && !event.hyper && !event.shift
37
44
  const name = event.name
@@ -91,7 +98,10 @@ export function navigation(context) {
91
98
  }
92
99
  function paste(event) {
93
100
  sync()
94
- if (active() && context.mode.current() === MODE) consume(event)
101
+ if (active() && context.mode.current() === MODE) {
102
+ if (!context.promptFocused() && !planReview()) leave()
103
+ else consume(event)
104
+ }
95
105
  }
96
106
  return { key, paste, leave, sync, active }
97
107
  }
package/src/tui.mjs CHANGED
@@ -2,72 +2,9 @@ import { Plugin } from "@opencode/plugin/tui"
2
2
  import { InputRenderable, TextareaRenderable } from "@opentui/core"
3
3
  import { createElement, spread } from "@opentui/solid"
4
4
  import { createEffect, createRoot, createSignal } from "solid-js"
5
- import { navigation } from "./navigation.mjs"
5
+ import { createSetup } from "./adapter.mjs"
6
6
 
7
7
  export default Plugin.define({
8
8
  id: "opencode-vim-navigation",
9
- setup(context) {
10
- const { renderer, keymap, ui } = context
11
- if (!renderer.keyInput?.prependListener || !keymap.mode?.push) {
12
- throw new Error("Transcript navigation requires the OpenCode 2.1.4 TUI API")
13
- }
14
- const [nav, setNav] = createSignal(false)
15
- const session = () => {
16
- const route = ui.router.current()
17
- return route.type === "session" ? route.sessionID : undefined
18
- }
19
- const required = ["session.line.up", "session.line.down", "session.page.up", "session.page.down", "session.first", "session.last"]
20
- const controller = navigation({
21
- session,
22
- mode: keymap.mode,
23
- promptFocused: () => renderer.currentFocusedEditor instanceof TextareaRenderable
24
- && !(renderer.currentFocusedEditor instanceof InputRenderable),
25
- ready: () => {
26
- const commands = new Set(keymap.commands().map((command) => command.id))
27
- if (required.every((id) => commands.has(id))) return true
28
- ui.toast.show({ message: "Transcript navigation: this OpenCode version lacks the expected scrolling commands", variant: "error" })
29
- return false
30
- },
31
- dispatch: (id) => keymap.dispatch(id),
32
- changed: setNav,
33
- })
34
- // OpenTUI's public global input listeners run before the focused editor.
35
- // Prepending also precedes OpenCode's own keymap, so NAV cannot submit or edit a draft.
36
- renderer.keyInput.prependListener("keypress", controller.key)
37
- renderer.keyInput.prependListener("paste", controller.paste)
38
- const dispose = createRoot((dispose) => {
39
- createEffect(() => {
40
- controller.sync()
41
- const id = session()
42
- // Never trap an incoming permission request or interactive form in NAV.
43
- if (id && (context.data.session.permission.list(id)?.length || context.data.session.form.list(id)?.length)) {
44
- controller.leave()
45
- }
46
- })
47
- return dispose
48
- })
49
- const removeStatus = ui.slot({
50
- append: "session.composer.top",
51
- render: () => {
52
- const element = createElement("box")
53
- spread(element, {
54
- height: 1,
55
- width: "100%",
56
- border: ["top"],
57
- get borderColor() { return context.theme.border.base },
58
- get title() { return nav() ? " NAV · i: input " : " INPUT · Ctrl+Q: navigate " },
59
- get titleColor() { return nav() ? context.theme.text.base : context.theme.text.muted },
60
- titleAlignment: "right",
61
- })
62
- return element
63
- },
64
- })
65
- return () => {
66
- renderer.keyInput.removeListener("keypress", controller.key)
67
- renderer.keyInput.removeListener("paste", controller.paste)
68
- controller.leave()
69
- removeStatus()
70
- dispose()
71
- }
72
- },
9
+ setup: createSetup({ InputRenderable, TextareaRenderable, createElement, spread, createEffect, createRoot, createSignal }),
73
10
  })