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 +157 -151
- package/package.json +4 -3
- package/src/adapter.mjs +109 -0
- package/src/navigation.mjs +13 -6
- package/src/tui.mjs +2 -65
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
|
|
5
|
-
|
|
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
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
+
Add the package to `~/.config/opencode/cli.json`:
|
|
26
23
|
|
|
27
|
-
|
|
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
|
-
|
|
37
|
+
npm view opencode-vim-navigation version
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
50
|
+
[npm package](https://www.npmjs.com/package/opencode-vim-navigation) ·
|
|
51
|
+
[GitHub](https://github.com/Raaghav-Arya/opencode-vim-navigation)
|
|
44
52
|
|
|
45
|
-
|
|
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
|
-
|
|
55
|
+
```sh
|
|
56
|
+
git clone https://github.com/Raaghav-Arya/opencode-vim-navigation.git
|
|
57
|
+
```
|
|
50
58
|
|
|
51
|
-
|
|
59
|
+
Then use its **absolute directory path** in `cli.json`:
|
|
52
60
|
|
|
53
61
|
```json
|
|
54
62
|
{
|
|
55
|
-
"plugins": ["opencode-vim-navigation
|
|
63
|
+
"plugins": ["/absolute/path/to/opencode-vim-navigation"]
|
|
56
64
|
}
|
|
57
65
|
```
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
108
|
-
multiplexer mappings that intercept
|
|
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
|
|
119
|
+
Use Node.js 22 or newer:
|
|
113
120
|
|
|
114
121
|
```sh
|
|
115
122
|
npm test
|
|
116
123
|
npm run check
|
|
117
|
-
npm
|
|
124
|
+
npm run test:package
|
|
118
125
|
```
|
|
119
126
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
150
|
-
|
|
187
|
+
loader expects a conventional root `tui` entrypoint; npm packages expose it
|
|
188
|
+
through `./tui`. Keep both when reorganizing source.
|
|
151
189
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
164
|
-
the former.
|
|
165
|
-
|
|
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.
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
###
|
|
219
|
+
### Account setup (once)
|
|
182
220
|
|
|
183
|
-
1. Create an account at [npmjs.com/signup](https://www.npmjs.com/signup)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
npm view opencode-vim-navigation name version --registry=https://registry.npmjs.org/
|
|
198
|
-
```
|
|
235
|
+
### Release
|
|
199
236
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|
package/src/adapter.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/navigation.mjs
CHANGED
|
@@ -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
|
|
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 (!
|
|
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)
|
|
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 {
|
|
5
|
+
import { createSetup } from "./adapter.mjs"
|
|
6
6
|
|
|
7
7
|
export default Plugin.define({
|
|
8
8
|
id: "opencode-vim-navigation",
|
|
9
|
-
setup(
|
|
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
|
})
|