opencode-vim-navigation 0.1.2 → 0.1.3

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