opencode-vimnav #
Keyboard tab navigation for OpenCode v2 TUI sessions,
with baked-in defaults: alt+h / alt+l switch session tabs, alt+shift+h /
alt+shift+l move the active tab within the strip. Install and forget — or
rebind every action through one options object.
The name is aspirational: more vim-style navigation may land here later. This is not vim ideology — just good defaults, chosen for the terminal they actually run in.
- Requirements: OpenCode v2 TUI (
engines.opencode >= 2.0.0), no build step — the host runssrc/TypeScript directly. - Runtime dependencies: none.
@opencode-ai/pluginis an optional peer dependency the host itself satisfies; the plugin imports nothing at runtime. - Move bindings additionally need the plugact host extension; everything else works on a stock host.
Contents #
- Why this exists
- Quick start
- What you get
- Options
- Validation
- Terminal compatibility
- Interaction with cli.json keybinds
- The plugact host extension
- Architecture
- Design decisions
- Develop
- Related
- License
Why this exists #
Two gaps, one accident:
- Tab switching already ships but is invisible under tmux. The v2 TUI
cycles tabs on
ctrl+tab/ctrl+shift+tab(oralt+down/alt+up) — butctrl+tabhas no legacy encoding, so inside tmux only the arrow alternates ever fire, and nobody discovers them. - Tab reordering has no keyboard path at all. Upstream exposes an
absolute
tabs.move(sessionID, index)on the plugin API, but no command or key reaches it; the only shipped reorder is mouse drag. - The accident: an earlier attempt bound
session.tab.previoustoctrl+h— which is byte0x08, parsed by the host's legacy keypress parser as backspace. The binding never fired; the byte deleted text from the prompt. This plugin encodes that lesson as a deny-list so it cannot recur.
The default keyset (vim) is the family that encodes cleanly through
tmux's legacy path. See terminal compatibility
for the full analysis and the other keysets.
Quick start #
Wire it into the plugins tuple of the global cli.json
(~/.config/opencode/cli.json) — wiring is the operator's call, this repo
never edits host config:
{
"plugins": [
{ "package": "file:///home/rektide/src/opencode-vimnav/src" }
]
}
That single line installs the default vim keyset. On the next TUI start:
alt+h / alt+l cycle tabs everywhere — including while typing in the
prompt — and, once plugact is present, alt+shift+h / alt+shift+l move
the active tab. Without plugact the move pair stays palette-dispatchable
(ctrl+p → "Move tab left/right") and explains its absence on run.
If you already rebind session.tab.previous / session.tab.next in
cli.json, use the split wiring
instead so the same keys are not bound twice.
What you get #
| Action | Command id | vim keyset (default) |
Implemented as |
|---|---|---|---|
| Previous tab | vimnav.tab.previous |
alt+h |
dispatches built-in session.tab.previous |
| Next tab | vimnav.tab.next |
alt+l |
dispatches built-in session.tab.next |
| Move tab left | vimnav.tab.move.left |
alt+shift+h |
tabs.moveBy(undefined, -1) (plugact) |
| Move tab right | vimnav.tab.move.right |
alt+shift+l |
tabs.moveBy(undefined, 1) (plugact) |
Exactly four bindings, one key per action — no bundled alternates. The
keyset option accepts "vim" (default) or false (palette-only); arrow
or bracket families are deliberately not shipped — build exactly what you
want through per-action binds.
All four register in a single global-mode keymap layer — active
everywhere, including while the prompt has focus — under group Tabs:
- Switching composes with the host: the commands dispatch the built-in
session.tab.*ids rather than reimplementing cycling, so wrapping, unread-cycle, and any host improvements come along for free. - Palette visibility: the move pair appears in the command palette
(
ctrl+p); the switch pair does not — the built-ins already own switch discovery there. All four ids remain stable palette/dispatch keys:context.keymap.dispatch("vimnav.tab.move.left"). - Lifecycle: the layer is registered from the plugin's
appslot claim and retires with it; setup returns the disposing cleanup. Hot-reload of the plugin re-registers cleanly.
Options #
Options arrive via the options tuple form of the plugins entry:
{
"package": "file:///home/rektide/src/opencode-vimnav/src",
"options": { /* VimnavOptions, below */ }
}
| Option | Type | Default |
|---|---|---|
enabled |
boolean |
true |
keyset |
"vim" | false |
"vim" |
binds |
false or a partial binds map |
the keyset table |
Keysets #
A keyset selects one key per action — the whole point is not bundling alternates someone's terminal cannot use anyway:
| Keyset | previous | next | moveLeft | moveRight | Where it works |
|---|---|---|---|---|---|
vim |
alt+h |
alt+l |
alt+shift+h |
alt+shift+l |
everywhere, tmux included (default) |
false |
— | — | — | — | nothing bound; commands stay palette-dispatchable |
updown (arrow) and bracket keysets were considered and deliberately
not shipped: arrows already belong to the host's shipped switch defaults
(and the obvious arrow move pair collides with its unread-cycle bindings),
and brackets cannot work under tmux at all. Per-action binds cover both
families for whoever wants them — e.g. keyset: false plus
{"previous": "alt+up", "next": "alt+down", …}, or a comma list like
"alt+h,ctrl+[" for cross-terminal coverage.
binds overrides the keyset #
binds entries override individual actions on top of the selected keyset.
Each value is a key string — comma lists allowed, e.g. "alt+j,alt+pageup"
— or false to unbind that action; absent keys inherit the keyset's
value:
| Configuration | previous | next | moveLeft | moveRight |
|---|---|---|---|---|
options: {} (or no options) |
alt+h |
alt+l |
alt+shift+h |
alt+shift+l |
"binds": {"previous": "alt+j"} |
alt+j |
alt+l (inherited) |
alt+shift+h |
alt+shift+l |
"binds": {"previous": false, "next": false} |
unbound | unbound | alt+shift+h |
alt+shift+l |
"keyset": false, "binds": {"moveLeft": "alt+shift+h", "moveRight": "alt+shift+l"} |
unbound | unbound | alt+shift+h |
alt+shift+l |
"binds": false |
unbound | unbound | unbound | unbound |
Unbound ≠ unregistered: commands keep their ids, palette entries, and
dispatch — only the key binding is dropped. Want cross-terminal coverage?
Combine families yourself with a comma list, e.g.
"binds": {"previous": "alt+h,ctrl+["} — deliberate, visible, and yours.
Key string grammar #
Same as the host's bind syntax: modifier+name tokens, comma-separated
alternates. Accepted names include letters, numbers, escape, return,
tab, backspace, arrows, f1–f12, home/end, pageup/pagedown;
modifiers ctrl, shift, alt, meta, super, hyper. Strings are
normalized (segments trimmed, re-joined canonically); the <leader> token
is host-side and not offered here — this plugin's job is direct keys.
Validation #
Anything unexpected throws at setup — the host surfaces the failure — instead of silently disarming a key:
- unknown top-level option keys (
valid: enabled, binds) - unknown
bindskeys (valid: previous, next, moveLeft, moveRight) - values that are neither a key string nor
false - empty comma segments (
"alt+h,,")
A small deny-list additionally rejects keys that cannot work in the terminal this plugin was designed for, so they fail at setup rather than mysteriously never firing:
| Rejected bind | Reason |
|---|---|
ctrl+h |
byte 0x08 parses as backspace under tmux |
alt+shift+f |
legacy ESC F remaps to right arrow |
alt+shift+b |
legacy ESC B remaps to left arrow |
(The deny-list is advisory-strict, not exhaustive — see the compatibility table for what else not to reach for.)
Terminal compatibility #
The author runs the TUI inside tmux, which shapes everything. OpenCode enables the kitty keyboard protocol where the terminal supports it; tmux does not forward it, so the host falls back to legacy byte decoding. What survives that path:
| Key form | tmux / legacy | kitty protocol | Used here |
|---|---|---|---|
alt+letter, alt+number |
✓ | ✓ | ✓ vim keyset |
alt+shift+letter (except f/b) |
✓ | ✓ | ✓ vim move keys |
CSI-modified arrows (alt+up etc.) |
✓ | ✓ | available via binds |
ctrl+], ctrl+\, ctrl+^, ctrl+_ |
✓ | ✓ | available via binds |
ctrl+[ / ctrl+shift+[ |
✗ (is ESC) | ✓ | available via binds |
ctrl+h |
✗ (backspace) | ✓ | ✗ denied |
ctrl+tab |
✗ | ✓ | ✗ |
alt+[ / alt+] / alt+, |
✗ (parser is alphanumeric-only for meta) | ✓ | ✗ |
Bracket keys therefore do nothing under tmux — binding a
never-arriving key event is inert, not harmful — and come alive the
moment the TUI runs in a kitty-protocol terminal (Ghostty, WezTerm, kitty,
foot, recent Alacritty) without tmux. Mixing families in one comma list
via binds is the way to cover both worlds with deliberate, visible
alternates.
tmux settings that help generally: escape-time 0 (snappier ESC-prefix
disambiguation) and extended-keys on (xterm modifyOtherKeys for
ctrl+shift+letter; the host parser understands the encoded form). Neither
rescues ctrl+[.
Interaction with cli.json keybinds #
Host keybind config (cli.json → keybinds) and this plugin can own the
same action. Binding the same key in both places is redundant at best
(confusing which-key displays at worst), so pick a split:
Recommended — host owns switching, plugin owns moving:
// cli.json
{
"keybinds": {
"session.tab.previous": "alt+h,alt+up,ctrl+[",
"session.tab.next": "alt+l,alt+down,ctrl+]"
},
"plugins": [
{
"package": "file:///home/rektide/src/opencode-vimnav/src",
"options": { "binds": { "previous": false, "next": false } }
}
]
}
Switching then works even with plugins disabled entirely; the plugin adds
exactly the move pair (its vim keyset move keys — alt+shift+h/
alt+shift+l — since those were left to inherit).
Plugin owns everything (drop the keybinds block, use bare wiring) —
one place to configure, at the cost of losing switch keys if the plugin is
ever absent.
Rebinding vimnav from cli.json (future) #
The plugact host extension also widens the host keybinds schema to
accept plugin command ids, so "vimnav.tab.move.left": "alt+pageup"
becomes valid host config that overrides the plugin's declared bind. One
caveat on that line: configuring a plugin command to "none" does not
suppress its declared bind — use the plugin's own binds options to
disable. Details in the plugact section.
The plugact host extension #
Tab switching uses only stock v2 host APIs. Tab moving upstream is
absolute only: context.ui.tabs publishes enabled / list / open /
focus / close plus move(sessionID, index) (jump to an absolute,
clamped index — both arguments required). A directional nudge does not
exist upstream. The
[plugact](file:///home/rektide/src/opencode-plugact) host patch line adds:
tabs.moveBy(direction: 1 | -1): boolean // shift the ACTIVE tab one slot
tabs.moveBy(sessionID: string, direction: 1 | -1): boolean // explicit tab; child ids root-resolve
tabs.cycle(direction: 1 | -1): boolean // switch next/prev, wrapping
moveBy clamps at the strip ends (no wrap); edge-clamped moves are
accepted no-ops returning true; false means tabs disabled or target
not open. The companion commit of plugact also widens the keybind schema
(see above).
opencode-vimnav never assumes the patch. It feature-detects
(typeof context.ui.tabs.moveBy === "function") at layer construction —
deliberately keyed on moveBy, a name upstream does not publish, so an
upstream-only host (which does ship absolute move) is correctly treated
as move-unavailable instead of binding keys that would silently no-op:
- plugact present — move commands bind their configured keys and call
moveBy(undefined, -1 | 1)on the active tab. Afalsereturn is a quiet no-op. - plugact absent — everything else still works. The move commands stay
registered and palette-dispatchable but are never key-bound
(whatever the options asked for — a key that cannot work is not wired),
and running one toasts:
tab move needs the plugact host extension. The run path re-checks the capability defensively, so the palette can never crash into a missing method.
plugact is a local feature line (~/src/opencode-plugact, bookmark
plugact), not an upstream release — obtaining its behavior means
composing it into the host build, an operator decision. This plugin is
declared compatible either way: package.json carries
peerDependencies["@opencode-ai/plugin"]: ">=0.0.0-dev-18695" with
peerDependenciesMeta.optional: true; the host provides the published
contract, plugact extends it.
Architecture #
src/
config/
binds.ts pure option decoding: the vim keyset table,
override-on-keyset binds composition, comma normalization,
fail-loud validation, deny-list (unit-tested)
tui/
context.ts local drift-catcher typing of the host slice: the FULL
published SlotMap/claim union and keymap command shape,
with plugact's tabs.moveBy/cycle typed optional; plus
hasTabMoveBy() feature detection
commands.ts pure layer-command builder: ids/titles/group/palette,
move pair gated on the capability (unit-tested)
plugin.ts { id, setup } entry: option decode, the app-claim
registration pattern, dispatching actions, cleanup
index.ts portable-core barrel
tui.ts sibling-name shim the host resolves as the TUI entrypoint
tests/
binds.test.ts option table semantics, validation, deny-list
commands.test.ts layer-command shape in every capability/config combination
plugin.test.ts stubbed-context integration: claim lifecycle, dispatch,
toasts, the upstream-absolute-move regression
Host-contract discipline (per the [plugin authoring guide](file:///home/rektide/archive/doc/opencode/plugins.md)):
- the full published
SlotMapand keymap command shape are typed locally rather than narrowed to the paths we use, so a host rename or removal of any published path fails typecheck here instead of degrading silently; - the keymap layer is registered from the
appclaim's render body — the documented lifecycle-owned pattern — exactly once per claim generation, and setup returns a cleanup that disposes the claim, retiring the layer; - switching composes with the host (dispatches built-ins) rather than reimplementing tab cycling;
- pure logic (decoding, command construction) is isolated from host surfaces, so the bulk of the plugin is tested without a renderer.
TypeScript throughout, no build step: the host runs src/ directly under
its Bun runtime. Formatting/lint/typecheck tooling is repo-local
(tsgo, vitest).
Design decisions #
- Keysets, not bundled defaults — the first shipped defaults put three
comma-listed alternates on each switch action ("works everywhere, sort
of") — too many bindings, half of them inert in any given terminal.
The keyset makes the terminal choice explicit: one key per action, four
bindings total,
falsefor palette-only installs. Anupdownkeyset and abracketkeyset were built and then dropped: arrows already belong to the host's switch defaults (with the unread-cycle collision), brackets cannot work under tmux —bindscovers both without shipping tables most installs should not use. bindsas override-on-keyset — with a table selector in play, partial override ("change exactly this action") is the useful composition; the earlier explicit-complete semantics (absent = unbound) predate keysets and made every customization restate all four keys. Omission still means "not bound" at the action level viafalse.alt+h/alt+las the default family — h/l encodes cleanly everywhere including tmux; arrows already belong to the host's shipped switch defaults; brackets cannot work under tmux at all (see the accident). Each remains reachable throughbinds.- Keying detection on
moveBy, notmove— upstreamv2@originships an absolutetabs.move(sessionID, index); keying onmovewould mistake an upstream-only host for plugact and bind move keys that silently no-op (moveBy(undefined, -1)against the absolute signature fails the open-tab guard). Caught in review after plugact's commit 1 revealed the upstream addition; regression-tested (upstream absolute move alone does not enable move keys). - Switch pair out of the palette — the built-in
session.tab.*commands already cover switch discovery; duplicate entries are noise. - Fail-loud options — a typo'd option key throwing at setup is a feature: the alternative is a silently missing binding, which in a keyboard-nav plugin is the product being broken.
- vitest over bun:test — matches the local exemplar plugins; the pure modules make renderer-free testing practical.
Develop #
pnpm install
pnpm run check:type # tsgo --noEmit
pnpm test # vitest run (36 tests)
pnpm run test:watch
Adding an action: extend BIND_KEYS + the keyset tables in
src/config/binds.ts, add the command in src/tui/commands.ts with a
stable vimnav.* id, wire its action in src/tui/plugin.ts, and cover it
in all three test files (config combinations, command shape,
stubbed-context behavior).
Related #
- [plugact](file:///home/rektide/src/opencode-plugact) — the host patch
line providing
tabs.moveBy/cycleand plugin-id keybind overrides. - [plugins.md](file:///home/rektide/archive/doc/opencode/plugins.md) — the canonical v2 plugin authoring guide (slot tree, keymap layers, plugin index).
- [config.md](file:///home/rektide/archive/doc/opencode/config.md) — host config surfaces, the keybind grammar, and the terminal key-encoding survival tables behind this plugin's defaults.
- [patches.md](file:///home/rektide/archive/doc/opencode/patches.md) — patch-stack governance for plugact and friends.
License #
MIT