opencode-vim-navigation 0.1.1 → 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,64 +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`, and loads the TSX entrypoint. OpenTUI and Solid are declared
18
- as peer dependencies so the package documents its host compatibility.
19
- 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.
20
19
 
21
- ## Install
20
+ ## Install from npm
22
21
 
23
- ### From a local checkout (available now)
22
+ Add the package to `~/.config/opencode/cli.json`:
24
23
 
25
- 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:
26
35
 
27
36
  ```sh
28
- git clone https://github.com/Raaghav-Arya/opencode-vim-navigation.git
37
+ npm view opencode-vim-navigation version
29
38
  ```
30
39
 
31
- Then add its **absolute directory path** to the `plugins` array in
32
- `~/.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.
33
43
 
34
- ```json
35
- {
36
- "plugins": ["/absolute/path/to/opencode-vim-navigation"]
37
- }
38
- ```
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.
39
49
 
40
- Merge this into any existing TUI config; do not overwrite other settings.
41
- 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)
42
52
 
43
- Alternatively, symlink the package directory into
44
- `~/.config/opencode/plugins/opencode-vim-navigation` for automatic discovery.
45
- Use **one installation method**, not both.
53
+ ### Install a local checkout
46
54
 
47
- ### From npm (after publication)
55
+ ```sh
56
+ git clone https://github.com/Raaghav-Arya/opencode-vim-navigation.git
57
+ ```
48
58
 
49
- Once the package has been published as `opencode-vim-navigation`, use:
59
+ Then use its **absolute directory path** in `cli.json`:
50
60
 
51
61
  ```json
52
62
  {
53
- "plugins": ["opencode-vim-navigation@0.1.0"]
63
+ "plugins": ["/absolute/path/to/opencode-vim-navigation"]
54
64
  }
55
65
  ```
56
66
 
57
- This belongs in **`cli.json`**, not the server plugin list in `opencode.json`.
58
- OpenCode V1-era documentation may mention `tui.json`; these instructions target V2.
59
- OpenCode installs the package. Pin a version for reproducible configuration.
60
- Until the first npm release, use the local-checkout installation above.
61
- 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.
62
70
 
63
71
  ## Keys
64
72
 
@@ -73,23 +81,24 @@ Check [npm](https://www.npmjs.com/package/opencode-vim-navigation) for availabil
73
81
  | **i** | Type normally | Return to INPUT, follow latest output, preserve draft |
74
82
  | **Escape / Ctrl+C** | OpenCode default | Leave NAV without moving the viewport, then perform OpenCode's normal interrupt/clear action |
75
83
 
76
- Only Ctrl+Q is intercepted in INPUT. In particular, Ctrl+U/D retain their
77
- normal prompt-editing/exit behavior until you enter NAV.
78
-
79
- In NAV, other keys, Enter, and pasted text are ignored to protect your draft
80
- and prevent accidental submissions. This is **transcript navigation**, not
81
- 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.
82
89
 
83
- Dialogs, autocomplete, composer panels, and other non-base modes keep their
84
- own keyboard handling. Incoming permission/form requests and session changes
85
- 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.
86
96
 
87
97
  ### Neovim / Sidekick
88
98
 
89
99
  Stay in Neovim **terminal mode**. OpenCode draws its own transcript, so normal
90
- terminal-buffer scrollback cannot reliably navigate the conversation.
91
-
92
- 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:
93
102
 
94
103
  ```lua
95
104
  cli = {
@@ -102,156 +111,159 @@ cli = {
102
111
  }
103
112
  ```
104
113
 
105
- Merge these options into your existing Sidekick configuration. Terminal or
106
- 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.
107
116
 
108
- ## Development
117
+ ## Development and verification
109
118
 
110
- Use Node.js 22 or newer for the development commands:
119
+ Use Node.js 22 or newer:
111
120
 
112
121
  ```sh
113
122
  npm test
114
123
  npm run check
115
- npm pack --dry-run
124
+ npm run test:package
116
125
  ```
117
126
 
118
- There are no development dependencies to install. `npm run check` checks the
119
- JavaScript controller and tests; it is **not** a TSX typecheck. OpenCode itself
120
- loads and compiles the TUI adapter.
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.
121
141
 
122
142
  ```text
123
143
  tui.ts Conventional local/package entrypoint
124
144
  src/
145
+ tui.mjs Host imports and plugin definition
146
+ adapter.mjs Host integration, request/focus guards, mode indicator
125
147
  navigation.mjs Mode state and input routing
126
- tui.tsx OpenCode adapter and mode indicator
127
148
  tests/
128
- 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
129
153
  ```
130
154
 
131
- The 13 tests mock the host and cover toggling, navigation, INPUT passthrough,
132
- paste/draft protection, dialog isolation, route cleanup, and missing commands.
133
- They do not replace interactive rendering tests.
134
-
135
- The initial implementation also passed a live pseudo-terminal smoke test
136
- against OpenCode 2.1.4: INPUT/NAV switched, navigation changed the viewport,
137
- and returning to INPUT preserved the draft, without submitting any prompt.
138
- Recheck the same behavior after changing the adapter or upgrading OpenCode.
139
-
140
- ### 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
185
+
186
+ The root `tui.ts` re-exports `src/tui.mjs`. OpenCode 2.1.4's local-directory
187
+ loader expects a conventional root `tui` entrypoint; npm packages expose it
188
+ through `./tui`. Keep both when reorganizing source.
189
+
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.
141
197
 
142
- The root `tui.ts` re-exports `src/tui.tsx`. OpenCode 2.1.4's local-directory
143
- loader looks for a conventional root `tui` entrypoint; npm packages also
144
- expose it via the `./tui` package export. Keep both when reorganizing source.
198
+ In **2.1.4**, `session.page.up/down` move by half of `scroll.height`, whereas
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.
145
201
 
146
202
  See the [OpenCode V2 CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli)
147
- for the host API and package conventions.
148
-
149
- The plugin uses OpenTUI's public prepended key/paste listeners to stop NAV
150
- input before it reaches the prompt. A named OpenCode keymap mode isolates
151
- normal prompt shortcuts. Host transcript commands scroll the actual
152
- conversation, including virtualized history/backfill; the plugin does not
153
- scrape a terminal buffer. Listeners, mode, effects, and the mode indicator
154
- are cleaned up when the plugin deactivates.
155
-
156
- In **2.1.4**, `session.page.up/down` move by half of `scroll.height`, whereas
157
- `session.half.page.up/down` move by a quarter. The plugin deliberately uses
158
- the former. It checks for expected commands before entering NAV, but future
159
- releases could retain names while changing semantics.
203
+ for host APIs and package conventions.
160
204
 
161
205
  ## Disable / uninstall
162
206
 
163
- Remove the package entry from `cli.json`, then restart OpenCode. If installed
164
- via automatic discovery instead, remove the symlink from `plugins/`.
165
- 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.
166
210
 
167
- The plugin ID in OpenCode's TUI plugin manager is `opencode-vim-navigation`.
211
+ The TUI plugin manager ID is `opencode-vim-navigation`.
168
212
 
169
213
  ## Maintainer: publishing to npm
170
214
 
171
- GitHub hosts the source; npm distributes the installable package. Publishing
172
- to GitHub does not automatically publish to npm. Public unscoped packages
173
- 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.
174
218
 
175
- ### First release
219
+ ### Account setup (once)
176
220
 
177
- 1. Create an account at [npmjs.com/signup](https://www.npmjs.com/signup) if
178
- needed, verify your email, and enable two-factor authentication in your
179
- account settings. GitHub CLI authentication does **not** log you into npm.
180
- Save your recovery codes privately; never commit tokens or recovery codes.
181
- 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:
182
225
 
183
226
  ```sh
184
- npm login --registry=https://registry.npmjs.org/
227
+ npm login --auth-type=web --registry=https://registry.npmjs.org/
185
228
  npm whoami --registry=https://registry.npmjs.org/
186
229
  ```
187
230
 
188
- 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.
189
234
 
190
- ```sh
191
- npm view opencode-vim-navigation name version --registry=https://registry.npmjs.org/
192
- ```
235
+ ### Release
193
236
 
194
- An E404 means no public package was found; it is not a reservation or a
195
- guarantee that npm will accept the name. If someone else owns it, choose
196
- a scoped name such as `@YOUR_NPM_USERNAME/opencode-vim-navigation` and
197
- update `package.json` and the installation examples before publishing.
198
- 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:
199
241
 
200
242
  ```sh
201
243
  npm test
202
244
  npm run check
245
+ npm run test:package
203
246
  npm pack --dry-run
204
247
  npm publish --dry-run
205
248
  ```
206
249
 
207
- The package should include `LICENSE`, `README.md`, `package.json`, `tui.ts`,
208
- and `src/`. Tests and GitHub workflows stay on GitHub, not in the tarball.
209
- Dry runs do not upload the package and do not prove your account has
210
- permission to publish. The `prepublishOnly` script reruns tests and
211
- JavaScript syntax checks before publication.
212
- 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:
213
253
 
214
254
  ```sh
215
255
  npm publish --access public
216
256
  ```
217
257
 
218
- Complete npm's browser / two-factor prompts. Do not paste authentication
219
- codes or tokens into issues or chat. A successful publish ends with
220
- `+ opencode-vim-navigation@0.1.0`.
221
- 6. Verify the release:
222
-
223
- ```sh
224
- npm view opencode-vim-navigation@0.1.0 version dist.tarball
225
- ```
226
-
227
- Replace the local path in your OpenCode `cli.json` with
228
- `opencode-vim-navigation@0.1.0` and restart OpenCode. Remove the
229
- "after publication" wording in this README once the package is live.
230
- 7. Optionally mark the published source on GitHub:
231
-
232
- ```sh
233
- git tag v0.1.0
234
- git push origin v0.1.0
235
- ```
236
-
237
- Tag the exact commit used for publication; commit any release changes first.
238
-
239
- ### Later releases
240
-
241
- npm versions cannot be overwritten or reused. For a compatible bug fix,
242
- start from a clean checkout, update the changelog/README as appropriate, run
243
- checks, then:
244
-
245
- ```sh
246
- npm version patch
247
- npm publish --access public
248
- git push origin main --follow-tags
249
- ```
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.
250
262
 
251
- `npm version patch` updates the version and creates a commit and tag. Use a
252
- minor or major bump when appropriate instead. Publish manually initially;
253
- if automated releases are added later, prefer npm trusted publishing rather
254
- 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.
255
267
 
256
268
  ## License
257
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vim-navigation",
3
- "version": "0.1.1",
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 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 ADDED
@@ -0,0 +1,10 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { InputRenderable, TextareaRenderable } from "@opentui/core"
3
+ import { createElement, spread } from "@opentui/solid"
4
+ import { createEffect, createRoot, createSignal } from "solid-js"
5
+ import { createSetup } from "./adapter.mjs"
6
+
7
+ export default Plugin.define({
8
+ id: "opencode-vim-navigation",
9
+ setup: createSetup({ InputRenderable, TextareaRenderable, createElement, spread, createEffect, createRoot, createSignal }),
10
+ })
package/tui.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Conventional entrypoint for OpenCode's local-directory plugin discovery.
2
- export { default } from "./src/tui.tsx"
2
+ export { default } from "./src/tui.mjs"
package/src/tui.tsx DELETED
@@ -1,68 +0,0 @@
1
- import { Plugin } from "@opencode/plugin/tui"
2
- import { InputRenderable, TextareaRenderable } from "@opentui/core"
3
- import { createEffect, createRoot, createSignal } from "solid-js"
4
- import { navigation } from "./navigation.mjs"
5
-
6
- export default Plugin.define({
7
- id: "opencode-vim-navigation",
8
- setup(context) {
9
- const { renderer, keymap, ui } = context
10
- if (!renderer.keyInput?.prependListener || !keymap.mode?.push) {
11
- throw new Error("Transcript navigation requires the OpenCode 2.1.4 TUI API")
12
- }
13
- const [nav, setNav] = createSignal(false)
14
- const session = () => {
15
- const route = ui.router.current()
16
- return route.type === "session" ? route.sessionID : undefined
17
- }
18
- const required = ["session.line.up", "session.line.down", "session.page.up", "session.page.down", "session.first", "session.last"]
19
- const controller = navigation({
20
- session,
21
- mode: keymap.mode,
22
- promptFocused: () => renderer.currentFocusedEditor instanceof TextareaRenderable
23
- && !(renderer.currentFocusedEditor instanceof InputRenderable),
24
- ready: () => {
25
- const commands = new Set(keymap.commands().map((command) => command.id))
26
- if (required.every((id) => commands.has(id))) return true
27
- ui.toast.show({ message: "Transcript navigation: this OpenCode version lacks the expected scrolling commands", variant: "error" })
28
- return false
29
- },
30
- dispatch: (id) => keymap.dispatch(id),
31
- changed: setNav,
32
- })
33
- // OpenTUI's public global input listeners run before the focused editor.
34
- // Prepending also precedes OpenCode's own keymap, so NAV cannot submit or edit a draft.
35
- renderer.keyInput.prependListener("keypress", controller.key)
36
- renderer.keyInput.prependListener("paste", controller.paste)
37
- const dispose = createRoot((dispose) => {
38
- createEffect(() => {
39
- controller.sync()
40
- const id = session()
41
- // Never trap an incoming permission request or interactive form in NAV.
42
- if (id && (context.data.session.permission.list(id)?.length || context.data.session.form.list(id)?.length)) {
43
- controller.leave()
44
- }
45
- })
46
- return dispose
47
- })
48
- const removeStatus = ui.slot({
49
- append: "session.composer.top",
50
- render: () => <box
51
- height={1}
52
- width="100%"
53
- border={["top"]}
54
- borderColor={context.theme.border.base}
55
- title={nav() ? " NAV · i: input " : " INPUT · Ctrl+Q: navigate "}
56
- titleColor={nav() ? context.theme.text.base : context.theme.text.muted}
57
- titleAlignment="right"
58
- />,
59
- })
60
- return () => {
61
- renderer.keyInput.removeListener("keypress", controller.key)
62
- renderer.keyInput.removeListener("paste", controller.paste)
63
- controller.leave()
64
- removeStatus()
65
- dispose()
66
- }
67
- },
68
- })