opencode-vim-navigation 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raaghav Arya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,258 @@
1
- # Temporary Holding Version
1
+ # opencode-vim-navigation
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
6
+
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.
9
+
10
+ ## Compatibility
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.
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.
20
+
21
+ ## Install
22
+
23
+ ### From a local checkout (available now)
24
+
25
+ Clone the repository:
26
+
27
+ ```sh
28
+ git clone https://github.com/Raaghav-Arya/opencode-vim-navigation.git
29
+ ```
30
+
31
+ Then add its **absolute directory path** to the `plugins` array in
32
+ `~/.config/opencode/cli.json`:
33
+
34
+ ```json
35
+ {
36
+ "plugins": ["/absolute/path/to/opencode-vim-navigation"]
37
+ }
38
+ ```
39
+
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.
42
+
43
+ Alternatively, symlink the package directory into
44
+ `~/.config/opencode/plugins/opencode-vim-navigation` for automatic discovery.
45
+ Use **one installation method**, not both.
46
+
47
+ ### From npm (after publication)
48
+
49
+ Once the package has been published as `opencode-vim-navigation`, use:
50
+
51
+ ```json
52
+ {
53
+ "plugins": ["opencode-vim-navigation@0.1.0"]
54
+ }
55
+ ```
56
+
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.
62
+
63
+ ## Keys
64
+
65
+ | Key | INPUT mode | NAV mode |
66
+ | --- | --- | --- |
67
+ | **Ctrl+Q** | Enter NAV | Return to INPUT and follow latest output |
68
+ | **j / k**, **Down / Up** | OpenCode default | One transcript line down/up |
69
+ | **gg / G** | OpenCode default | Jump to beginning/end |
70
+ | **Ctrl+U / Ctrl+D** | OpenCode default | Half a viewport up/down |
71
+ | **PageUp / PageDown** | OpenCode default | Half a viewport up/down |
72
+ | **Ctrl+Home / Ctrl+End** | OpenCode default | Jump to beginning/end |
73
+ | **i** | Type normally | Return to INPUT, follow latest output, preserve draft |
74
+ | **Escape / Ctrl+C** | OpenCode default | Leave NAV without moving the viewport, then perform OpenCode's normal interrupt/clear action |
75
+
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.
82
+
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.
86
+
87
+ ### Neovim / Sidekick
88
+
89
+ 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:
93
+
94
+ ```lua
95
+ cli = {
96
+ tools = {
97
+ opencode = { native_scroll = true },
98
+ },
99
+ win = {
100
+ keys = { stopinsert = false },
101
+ },
102
+ }
103
+ ```
104
+
105
+ Merge these options into your existing Sidekick configuration. Terminal or
106
+ multiplexer mappings that intercept these keys must also be disabled.
107
+
108
+ ## Development
109
+
110
+ Use Node.js 22 or newer for the development commands:
111
+
112
+ ```sh
113
+ npm test
114
+ npm run check
115
+ npm pack --dry-run
116
+ ```
117
+
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.
121
+
122
+ ```text
123
+ tui.ts Conventional local/package entrypoint
124
+ src/
125
+ navigation.mjs Mode state and input routing
126
+ tui.tsx OpenCode adapter and mode indicator
127
+ tests/
128
+ navigation.test.mjs Controller tests using node:test
129
+ ```
130
+
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
141
+
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.
145
+
146
+ 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.
160
+
161
+ ## Disable / uninstall
162
+
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.
166
+
167
+ The plugin ID in OpenCode's TUI plugin manager is `opencode-vim-navigation`.
168
+
169
+ ## Maintainer: publishing to npm
170
+
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.
174
+
175
+ ### First release
176
+
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:
182
+
183
+ ```sh
184
+ npm login --registry=https://registry.npmjs.org/
185
+ npm whoami --registry=https://registry.npmjs.org/
186
+ ```
187
+
188
+ 3. Check the package name:
189
+
190
+ ```sh
191
+ npm view opencode-vim-navigation name version --registry=https://registry.npmjs.org/
192
+ ```
193
+
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:
199
+
200
+ ```sh
201
+ npm test
202
+ npm run check
203
+ npm pack --dry-run
204
+ npm publish --dry-run
205
+ ```
206
+
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**:
213
+
214
+ ```sh
215
+ npm publish --access public
216
+ ```
217
+
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
+ ```
250
+
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.
255
+
256
+ ## License
257
+
258
+ [MIT](LICENSE) © 2026 Raaghav Arya.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "opencode-vim-navigation",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Modal Vim-style transcript navigation for the OpenCode terminal UI",
5
+ "license": "MIT",
6
+ "author": "Raaghav Arya",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Raaghav-Arya/opencode-vim-navigation.git"
10
+ },
11
+ "homepage": "https://github.com/Raaghav-Arya/opencode-vim-navigation#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Raaghav-Arya/opencode-vim-navigation/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "registry": "https://registry.npmjs.org/"
18
+ },
19
+ "type": "module",
20
+ "keywords": ["opencode", "opencode-plugin", "tui", "vim", "navigation"],
21
+ "exports": {
22
+ "./tui": "./tui.ts"
23
+ },
24
+ "files": ["tui.ts", "src/"],
25
+ "peerDependencies": {
26
+ "@opentui/core": ">=0.6.8 <0.7.0",
27
+ "@opentui/solid": ">=0.6.8 <0.7.0",
28
+ "solid-js": "^1.9.12"
29
+ },
30
+ "scripts": {
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"
34
+ }
35
+ }
@@ -0,0 +1,97 @@
1
+ // Input routing is separate from the TUI adapter so it can be tested without a server.
2
+ export function navigation(context) {
3
+ const MODE = "transcript-navigation"
4
+ let popMode
5
+ let sessionID
6
+ let pendingG = false
7
+
8
+ const active = () => popMode !== undefined
9
+ function leave(follow = false) {
10
+ if (!active()) return
11
+ popMode()
12
+ popMode = undefined
13
+ pendingG = false
14
+ sessionID = undefined
15
+ context.changed(false)
16
+ if (follow) context.dispatch("session.last")
17
+ }
18
+ function sync() {
19
+ if (active() && context.session() !== sessionID) leave()
20
+ }
21
+ function scroll(id) {
22
+ context.dispatch(id)
23
+ }
24
+ function consume(event) {
25
+ event.preventDefault()
26
+ event.stopPropagation()
27
+ }
28
+ function key(event) {
29
+ sync()
30
+ if (!context.session()) return
31
+ // Dialogs, permission forms, autocomplete, and embedded terminals own their keys.
32
+ const mode = context.mode.current()
33
+ if (mode !== "base" && mode !== MODE) return
34
+ if (!active() && !context.promptFocused()) return
35
+ const plain = !event.ctrl && !event.meta && !event.super && !event.hyper
36
+ const ctrl = event.ctrl && !event.meta && !event.super && !event.hyper && !event.shift
37
+ const name = event.name
38
+ if (ctrl && name === "q") {
39
+ consume(event)
40
+ if (active()) leave(true)
41
+ else {
42
+ if (!context.ready()) return
43
+ sessionID = context.session()
44
+ popMode = context.mode.push(MODE)
45
+ context.changed(true)
46
+ }
47
+ return
48
+ }
49
+ if (!active()) return // INPUT mode retains every default except Ctrl+Q.
50
+ // OpenCode 2.1.4's session.page.* commands move by half the viewport;
51
+ // session.half.page.* surprisingly move by a quarter. Use the actual half-page commands.
52
+ const controlScroll = ctrl && ({ u: "session.page.up", d: "session.page.down" })[name]
53
+ const pageScroll = plain && ({
54
+ pageup: "session.page.up", pagedown: "session.page.down",
55
+ })[name]
56
+ const edgeScroll = ctrl && ({ home: "session.first", end: "session.last" })[name]
57
+ if (controlScroll || pageScroll || edgeScroll) {
58
+ consume(event)
59
+ pendingG = false
60
+ scroll(controlScroll || pageScroll || edgeScroll)
61
+ return
62
+ }
63
+ if ((plain && name === "escape") || (ctrl && name === "c")) {
64
+ // Let OpenCode perform its usual interrupt/clear action after leaving NAV.
65
+ leave()
66
+ return
67
+ }
68
+ consume(event) // Protect the draft, including Enter and unknown shortcuts.
69
+ if (plain && name === "i" && !event.shift) {
70
+ leave(true)
71
+ return
72
+ }
73
+ const upperG = plain && (name === "G" || (name === "g" && event.shift))
74
+ if (upperG) {
75
+ pendingG = false
76
+ scroll("session.last")
77
+ return
78
+ }
79
+ if (plain && name === "g" && !event.shift) {
80
+ if (pendingG) scroll("session.first")
81
+ pendingG = !pendingG
82
+ return
83
+ }
84
+ pendingG = false
85
+ if (!plain || event.shift) return
86
+ const command = ({
87
+ j: "session.line.down", down: "session.line.down",
88
+ k: "session.line.up", up: "session.line.up",
89
+ })[name]
90
+ if (command) scroll(command)
91
+ }
92
+ function paste(event) {
93
+ sync()
94
+ if (active() && context.mode.current() === MODE) consume(event)
95
+ }
96
+ return { key, paste, leave, sync, active }
97
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,68 @@
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
+ })
package/tui.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Conventional entrypoint for OpenCode's local-directory plugin discovery.
2
+ export { default } from "./src/tui.tsx"