Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ jobs:
- name: Typecheck
run: bun run typecheck

- name: Check formatting
run: bun run format:check

- name: Test units and components
run: bun run test

Expand Down
5 changes: 5 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"semi": false,
"printWidth": 120,
"tabWidth": 2
}
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ resolve the latest OpenCode release. Downloaded binaries are cached under

Use `bun run build` to build the plugin into `dist/`.

Run `bun run format` to format the code, or `bun run format:check` to check it.

## Code layout

- `tui.tsx` re-exports the plugin entry point from `src/plugin.tsx`.
- `src/vim/` contains the editing adapter, editor interface, keymaps, and text objects.
- `src/ui/` contains UI-specific Vim handling: composer tabs, question forms,
transcript navigation, and live terminal controls. Shared native-key
forwarding lives in `native-keys.tsx`.
- `src/plugin.tsx` wires the handlers together and routes keyboard events.
- `src/readers/` contains the message and tool-output readers.

## Adding tests

Put unit tests in `test/unit/`, integration tests in `test/integration/`, and
Expand Down
46 changes: 45 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,55 @@ entering session mode.
Mappings apply to `insert`, `normal`, `visual`, and `visual-line` editing modes in
the prompt and search dialogs. Insert-mode mappings also apply while typing a
question answer. Use `keymaps.session` for transcript browsing and its
message/tool modals.
message/tool modals, and `keymaps.panes` for shared prompt/terminal controls.

See [Custom Keymaps](./keymap-actions.md) for actions, key notation, and examples.
See [Keybindings and Modes](./vim-behavior.md) for the default behavior.

### Pane controls

`keymaps.panes` uses the same `command:<id>` actions as editing mappings. It works
in the prompt, transcript browsing, and live terminal input, but not in dialogs,
question forms, or the composer picker. Active pane mappings take precedence over
editing mappings; unavailable commands leave the key alone.

These defaults are active even when `panes` is omitted:

| Key | Action |
| --- | --- |
| `<C-/>` | `command:opencode-vim.terminal.toggle` |
| `<M-h>` | `command:pane.focus.left` |
| `<M-l>` | `command:pane.focus.right` |

Add this inside `options.vim` to replace them with Alt+t and Alt+a/d:

```json
{
"keymaps": {
"panes": {
"<C-/>": "passthrough",
"<M-h>": "passthrough",
"<M-l>": "passthrough",
"<M-t>": "command:opencode-vim.terminal.toggle",
"<M-a>": "command:pane.focus.left",
"<M-d>": "command:pane.focus.right"
}
}
}
```

Omitted defaults stay active. `passthrough` disables a pane mapping and releases
that key to Vim editing or the child application. The footer follows the mappings.
`<C-_>` and `<C-/>` refer to the same terminal chord; disabling or remapping either
also changes the legacy alias.

Pane mappings require one key or chord, not sequences like `<C-w>h`, so shell
typing is never buffered. They accept `command:<id>` or `passthrough`, and support
Alt (`<M-h>`) and Ctrl punctuation (`<C-/>`) in addition to ordinary keys. The
named terminal toggle is also usable from existing normal-mode command mappings.
It remains available only in prompt normal mode, transcript browsing, or live
terminal input; pane focus commands also work in prompt insert mode.

### Cursor styles

Insert mode defaults to a blinking line cursor. Normal, visual, and visual-line
Expand Down
27 changes: 24 additions & 3 deletions docs/keymap-actions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Custom Keymaps

Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` inside
Each entry maps keys to an action in one scope. Put `keymaps` inside
`options.vim` in your plugin's `cli.json` entry; see [Configuration](./configuration.md).

```json
Expand All @@ -19,6 +19,11 @@ Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` insid
"session": {
"<Tab>": "passthrough",
"<C-w>w": "switch-panel"
},
"panes": {
"<C-/>": "command:opencode-vim.terminal.toggle",
"<M-h>": "command:pane.focus.left",
"<M-l>": "command:pane.focus.right"
}
}
}
Expand All @@ -32,6 +37,14 @@ and its message/tool modals; use `sessionKey` to change the session toggle.
Insert-mode mappings also apply to question answers; `submit` uses the question's
native confirm/submit action.

`panes` mappings share command actions between the prompt, transcript browsing,
and live terminal input. They support one chord at a time, not editing sequences.
See [Pane controls](./configuration.md#pane-controls) for remapping or disabling
the terminal defaults.

The `panes` example shows the defaults. To remap one, set its original key to
`passthrough` and assign the command to your preferred key.

| Action | Behavior |
| --- | --- |
| `normal` | Enter normal mode |
Expand All @@ -40,7 +53,7 @@ native confirm/submit action.
| `command:<id>` | Dispatch an active OpenCode command |
| Vim key sequence, such as `y$` | Run those Vim keys |
| `switch-panel` | Session only: switch between available panels |
| `passthrough` | Session only: leave a single key to OpenCode without consuming it |
| `passthrough` | Session/panes only: stop intercepting a single key |

Insert-mode mappings support only `normal`, `submit`, `command:<id>`, or Escape
(`"<Esc>"` / `"<C-[>"`). Other editing modes support all editing actions above.
Expand All @@ -60,6 +73,10 @@ Use printable ASCII characters; uppercase letters represent shifted keys. Use
Special keys are `<Esc>`, `<CR>`, `<Tab>`, `<BS>`, `<Del>`, `<Space>`, and `<C-a>`
through `<C-z>`. Ctrl letters must be lowercase: `<C-s>`, not `<C-S>`.

The `panes` scope additionally supports Alt notation such as `<M-h>`, Ctrl
punctuation such as `<C-/>`, and named arrow keys such as `<Left>`. These extra
chords are not supported by the editor's sequence parser.

Examples: `gg`, `kj`, `Y`, `<C-s>`, or `g<CR>`. In JSON, escape a backslash, as in
`"\\s"` for a backslash followed by `s`.

Expand All @@ -84,6 +101,9 @@ Commands run only when available in the current UI context.
| `prompt.history.previous` | Load the previous prompt |
| `prompt.history.next` | Load the next prompt |
| `opencode-vim.toggle` | Toggle Vim mode |
| `opencode-vim.terminal.toggle` | Show/focus the terminal, or hide it from live input without terminating it |
| `pane.focus.left` | Focus OpenCode without hiding the right pane |
| `pane.focus.right` | Focus the visible right pane without creating one |

See [OpenCode's command reference](https://opencode.ai/v2/docs/cli/keybinds) for
the full list. Installed plugins can register additional commands.
Expand All @@ -92,6 +112,7 @@ the full list. Installed plugins can register additional commands.

- Check the mode and whether the action is supported in it.
- Use the exact key notation above; literal spaces and names such as `<C-S>` or
`<Up>` are not supported in custom mapping sequences.
`<Up>` are not supported in editor mapping sequences. Pane mappings accept
single named arrow keys, but reject multi-key sequences.
- Command mappings need an active command in the current context.
- Enable `debug` in [Configuration](./configuration.md) to inspect rejected mappings.
46 changes: 46 additions & 0 deletions docs/vim-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,52 @@ The footer follows the active editor's mode. Closing the dialog restores the
prompt's mode or the session footer. Prompt and dialog edits have separate undo
histories.

## Subagents, Shell, and Terminals

The panel opened with Down uses Vim navigation regardless of the prompt's mode.
Use `h`/`l` to switch tabs and `j`/`k` to move between entries. Enter selects an
entry; `Esc` or `Ctrl+[` closes the panel. Arrow keys and existing shortcuts such
as `Ctrl+a` to show inactive subagents still work.

Navigation follows OpenCode's behavior, including wrapping and returning to the
prompt when moving up from the first subagent or shell entry. Closing the panel
preserves your prompt text and Vim mode.

## Live side terminals

From prompt normal mode or transcript browsing, `Ctrl+/` opens or focuses the
side terminal. In live terminal input, it hides the pane and
returns to the prompt without terminating the process. If no terminal exists,
OpenCode creates one. While the terminal has focus, a single footer below the
prompt shows `TERMINAL · Alt+h/l swap · Ctrl+/ hide`, styled like the session footer.

`Alt+h` focuses OpenCode and `Alt+l` focuses the visible right pane, including
while typing in the prompt. These switch focus without hiding or creating a
terminal. They are reserved from the child; shell Backspace and `Ctrl+l`
clear-screen remain native.

`Ctrl+_` is accepted too because legacy terminals encode `Ctrl+/` that way. This
chord is reserved rather than sent to the child, where it commonly means undo.
Disabling Vim restores native behavior.

Remap or disable these defaults through `keymaps.panes`; see
[Pane controls](./configuration.md#pane-controls). Both live input interception
and the footer follow those mappings. Existing editor `command:` mappings can
also call `opencode-vim.terminal.toggle`.

The live terminal sends input directly to its shell or application. Ordinary
Vim keys, Escape, and prompt mappings are not intercepted: Neovim, shell history,
and interactive tools keep their own bindings.

There is no plugin-specific history or copy mode. Use OpenCode's native mouse-wheel
scrollback and selection, or the shell/application's own commands. `Ctrl+\ Ctrl+n`
is not intercepted. Prompt text and Vim mode remain separate from terminal input.

OpenCode's native leader shortcuts still manage the live panes: `Ctrl+x` followed
by Left/Right changes focus, Down opens the terminal picker, and Up hides the
terminal without terminating its process. Leaving transcript browsing for another
pane ends session mode so it cannot swallow terminal input.

## Questions

Questions start in normal mode. `j`/`k` move between every answer, including away
Expand Down
21 changes: 19 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode-vim",
"version": "0.0.27",
"version": "0.0.28",
"license": "MIT",
"type": "module",
"repository": {
Expand All @@ -15,6 +15,8 @@
"build": "bun scripts/build.ts",
"prepack": "bun run build",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"{src,test,scripts}/**/*.{ts,tsx}\" tui.tsx",
"format:check": "prettier --check \"{src,test,scripts}/**/*.{ts,tsx}\" tui.tsx",
"test": "bun test --conditions=browser --preload @opentui/solid/preload ./test/unit ./test/integration",
"test:e2e": "bun test/e2e/run.ts",
"bench": "bun test/benchmark.ts"
Expand All @@ -33,6 +35,7 @@
"@opencode/theme": "2.0.10",
"@types/bun": "^1.3.14",
"@types/node": "^24.0.0",
"prettier": "3.9.9",
"typescript": "^5.9.0"
},
"files": [
Expand Down
40 changes: 18 additions & 22 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,27 @@ import { runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"

// npm sources live under node_modules, where the host's Solid source transform
// does not run. Compile JSX here and explicitly use the host's shared modules.
const shared = new Set([
"@opencode/plugin/tui",
"@opentui/core",
"@opentui/solid",
"solid-js",
"solid-js/store",
])
const shared = new Set(["@opencode/plugin/tui", "@opentui/core", "@opentui/solid", "solid-js", "solid-js/store"])

const result = await Bun.build({
entrypoints: ["./tui.tsx"],
outdir: "./dist",
target: "bun",
format: "esm",
packages: "external",
external: ["opentui:*"],
plugins: [createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (shared.has(specifier)) return runtimeModuleIdForSpecifier(specifier)
return null
},
})],
entrypoints: ["./tui.tsx"],
outdir: "./dist",
target: "bun",
format: "esm",
packages: "external",
external: ["opentui:*"],
plugins: [
createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (shared.has(specifier)) return runtimeModuleIdForSpecifier(specifier)
return null
},
}),
],
})

if (!result.success) {
for (const log of result.logs) console.error(log)
process.exit(1)
for (const log of result.logs) console.error(log)
process.exit(1)
}
11 changes: 9 additions & 2 deletions src/clipboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// .tsx lets OpenCode's runtime loader supply its shared OpenTUI imports.
import { createClipboard, createHostClipboard, createRendererClipboardAdapter, type RendererClipboardBoundary } from "@opentui/core"
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
type RendererClipboardBoundary,
} from "@opentui/core"

export type VimClipboard = ReturnType<typeof createVimClipboard>

Expand All @@ -22,7 +27,9 @@ export function createVimClipboard(renderer: RendererClipboardBoundary) {
try {
const result = await clipboard.writeText(text, { destination: "all-available", selection: "clipboard" })
return result.host.status === "written" || result.terminal.status === "attempted"
} catch { return false }
} catch {
return false
}
})
writes = result.then(() => {})
return result
Expand Down
Loading
Loading