Some keybindings for navigation in opencode
README.md

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 runs src/ TypeScript directly.
  • Runtime dependencies: none. @opencode-ai/plugin is 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 #

Two gaps, one accident:

  1. Tab switching already ships but is invisible under tmux. The v2 TUI cycles tabs on ctrl+tab / ctrl+shift+tab (or alt+down / alt+up) — but ctrl+tab has no legacy encoding, so inside tmux only the arrow alternates ever fire, and nobody discovers them.
  2. 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.
  3. The accident: an earlier attempt bound session.tab.previous to ctrl+h — which is byte 0x08, 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 app slot 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 binds keys (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. A false return 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 SlotMap and 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 app claim'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, false for palette-only installs. An updown keyset and a bracket keyset were built and then dropped: arrows already belong to the host's switch defaults (with the unread-cycle collision), brackets cannot work under tmux — binds covers both without shipping tables most installs should not use.
  • binds as 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 via false.
  • alt+h / alt+l as 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 through binds.
  • Keying detection on moveBy, not move — upstream v2@origin ships an absolute tabs.move(sessionID, index); keying on move would 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).

  • [plugact](file:///home/rektide/src/opencode-plugact) — the host patch line providing tabs.moveBy/cycle and 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