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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Typecheck
run: bun run typecheck

- name: Test source and npm package
- name: Test units and components
run: bun run test

e2e:
Expand Down Expand Up @@ -72,7 +72,7 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Test in OpenCode
- name: Test source and npm package in OpenCode
run: bun run test:e2e

- name: Upload failure diagnostics
Expand Down
24 changes: 18 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ bun run test
bun run test:e2e
```

- `test` covers editing behavior, plugin integration, source and npm package
loading, and comparisons against a pinned Neovim version downloaded
automatically.
- `test` covers our editing, clipboard and reader components, and comparisons
against a pinned Neovim version downloaded automatically.
- `test:e2e` builds and packs the current plugin, then runs real terminal
interactions against the latest stable OpenCode 2 release. Each scenario gets
isolated configuration and a fresh session, with imported fixture transcripts
for message-reader and history tests. No model requests are submitted.
for message-reader and transcript history tests. Live response and prompt
history scenarios use a local controlled model stream, without paid inference.
Source and packed-package runtime loading, reactivity and cleanup are tested
inside real OpenCode too.
Captures, logs, and results are saved under `test-results/e2e/`.

Run individual scenarios with `bun run test:e2e message-reader`.
Expand All @@ -56,9 +58,19 @@ shared fixtures and setup in `test/helpers/`.
Add focused regression coverage for bug fixes. Use E2E scenarios when the behavior
depends on real OpenCode keyboard handling, focus, dialogs, or tabs.

Test plugin-owned behavior directly with OpenTUI: editing and clipboard tests use
`test/helpers/fixture.ts`; reader component tests use `test/helpers/reader.tsx`.
These fixtures mount our components, not the plugin entrypoint. They may control
external boundaries such as desktop clipboard access and record outgoing calls.
Do not mount the plugin in a fake OpenCode context or imitate host mode changes,
transcript trees, scrolling, history, dialogs, or command implementations. Test
those integrations through real OpenCode E2E scenarios. See `test/README.md` for
the coverage map.

E2E scenarios live in `test/e2e/scenarios/` and are registered in
`test/e2e/run.ts`. Reuse the shared fixture and terminal helpers, and wait for
expected screen content rather than using fixed delays.
`test/e2e/run.ts`. Prepared conversations belong in `test/e2e/data/`; shared
setup, drivers and assertions belong in `test/e2e/support/`. Reuse those helpers
and wait for expected screen content rather than using fixed delays.

## Pull requests

Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ entering session mode.
### Custom keymaps

Mappings apply to `insert`, `normal`, `visual`, and `visual-line` editing modes in
the prompt and search dialogs. Use `keymaps.session` for transcript browsing and
its message/tool modals.
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.

See [Custom Keymaps](./keymap-actions.md) for actions, key notation, and examples.
See [Keybindings and Modes](./vim-behavior.md) for the default behavior.
Expand Down
2 changes: 2 additions & 0 deletions docs/keymap-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` insid
Mappings apply while editing the prompt or a search dialog, in `insert`, `normal`,
`visual`, or `visual-line` mode. `session` mappings apply to transcript browsing
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.

| Action | Behavior |
| --- | --- |
Expand Down
16 changes: 16 additions & 0 deletions docs/vim-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ 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.

## Questions

Questions start in normal mode. `j`/`k` move between every answer, including away
from "Type your own answer". `h`/`l` or Tab switch question fields. Enter selects
an option, and Space toggles a multi-select answer.

Press `i` to open or reopen the custom answer. Enter also opens an empty custom
answer; for an existing multi-select answer it keeps the native toggle behavior.
Type in insert mode; `Esc`, `Ctrl+[`, or your configured insert-mode mapping to `normal`
returns to navigation and preserves the answer. `Esc` in normal mode dismisses
the question. The mode is shown above the question, and the prompt's previous
mode is restored when it closes.

Insert mappings use the same configuration and timeout as the prompt. There is
no default two-letter binding for leaving insert mode.

## Vim compatibility

This is a Vim-style subset powered by `@vimee/core`, with OpenCode-specific
Expand Down
203 changes: 203 additions & 0 deletions src/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type { Context } from "@opencode/plugin/tui/context"
import { KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core"
import { createEffect, onCleanup, Show, untrack } from "solid-js"
import type { PromptContext } from "./modules/vim/actions"
import type { VimConfig } from "./modules/vim/config"
import { editInput } from "./modules/vim/edit"
import { keyNotation } from "./modules/vim/keys"
import type { VimLog } from "./modules/vim/log"
import { createVimState } from "./modules/vim/state"
import { createVimeeAdapter } from "./modules/vim/vimee"
import { VimStatus } from "./modules/vim/status"

export function createFormMode(context: Context, config: VimConfig, log: VimLog, enabled: () => boolean) {
const state = createVimState("normal", log)
const adapter = createVimeeAdapter(state, config, log)
const active = () => enabled() && context.keymap.mode.current() === "form"
let input: typeof context.renderer.currentFocusedEditor = null
let cursorStyle: CursorStyleOptions | undefined
let forwarding = false
let opening: Array<KeyEvent | PasteEvent> | undefined
const ctx: PromptContext = {
api: {
renderer: context.renderer,
keymap: {
dispatchCommand(command) {
const ok = context.keymap.commands().some((item) => item.id === command)
if (ok) context.keymap.dispatch(command)
return { ok }
},
},
theme: { get current() { return {
background: context.theme.background.base,
info: context.theme.text.feedback.info.base,
warning: context.theme.text.feedback.warning.base,
} } },
},
prompt: () => input ? {
current: { input: input.plainText, mode: "normal", parts: [] },
set: (value) => { if (input) editInput(input, value.input, context.renderer.widthMethod) },
submit: () => sendKey("return", "\r"),
blur: () => input?.blur(),
} : undefined,
requestRender: () => context.renderer.requestRender(),
}

const removeStatus = context.ui.slot({
append: "session.composer.top",
render: () => <Show when={active()}>
<box paddingLeft={3} marginBottom={1} flexDirection="row">
<VimStatus mode={state.mode} enabled={enabled} theme={{
success: context.theme.text.feedback.success.base,
warning: context.theme.text.feedback.warning.base,
}} />
<text fg={context.theme.text.muted}>
{state.mode() === "normal" ? "j/k select · i type an answer" : "esc normal"}
</text>
</box>
</Show>,
})

createEffect(() => {
if (!active()) opening = undefined
untrack(focus)
})
createEffect(() => {
const style = config.cursorStyles[state.mode()]
if (input && !input.isDestroyed) input.cursorStyle = style
})
onCleanup(() => {
opening = undefined
adapter.cleanup()
restoreCursor()
removeStatus()
})

function restoreCursor() {
if (input && !input.isDestroyed && cursorStyle) input.cursorStyle = cursorStyle
}

function focus() {
const next = active() ? context.renderer.currentFocusedEditor : null
if (next === input) return
adapter.suspend()
restoreCursor()
input = next
cursorStyle = input?.cursorStyle
state.setMode(input ? "insert" : "normal")
if (input) input.cursorStyle = config.cursorStyles[state.mode()]
}

function consume(event: KeyEvent | PasteEvent) {
event.preventDefault()
event.stopPropagation()
}

function sendKey(name: string, sequence: string) {
// Form actions are inline host bindings, without dispatchable command IDs.
forwarding = true
try {
context.renderer.keyInput.emit("keypress", new KeyEvent({
name, sequence, raw: sequence, ctrl: false, meta: false, shift: false,
option: false, number: false, eventType: "press", source: "raw",
}))
} finally {
forwarding = false
}
}

function waitForEditor() {
const queued: Array<KeyEvent | PasteEvent> = []
opening = queued
// OpenCode publishes a newly opened answer editor in a microtask. Keep
// burst typing here so its first keys also use the configured mappings.
queueMicrotask(() => queueMicrotask(() => {
if (opening !== queued) return
opening = undefined
focus()
for (const event of queued) {
if (!active()) break
if (event instanceof PasteEvent) context.renderer.keyInput.emit("paste", event)
else context.renderer.keyInput.emit("keypress", event)
}
}))
}

function paste(event: PasteEvent) {
if (!active()) return
if (opening) {
opening.push(new PasteEvent(event.bytes, event.metadata))
consume(event)
} else if (!input) {
waitForEditor()
}
}

function handle(event: KeyEvent) {
if (!active()) return false
if (forwarding) return true
if (opening) {
opening.push(new KeyEvent(event))
consume(event)
return true
}
if (context.keymap.pending().length) return true
const key = keyNotation(event as never)
if (!key) return true

if (state.mode() === "insert") {
const editor = input
const handled = adapter.handle(event as never, key, ctx)
if (handled) consume(event)
if (state.mode() === "normal" && input === editor) {
// Escape closes a custom answer, but dismisses a text-only form.
// Only forward it when the host exposes the close-edit action.
if (context.keymap.active().some((item) => item.group === "Form" && item.description === "Close answer edit")) {
sendKey("escape", "\x1b")
}
}
return true
}

if (event.ctrl || event.meta || event.option || event.super || event.hyper) {
if (key === "<C-[>") {
consume(event)
sendKey("escape", "\x1b")
}
return true
}
if (key === "i") {
consume(event)
if (input) state.setMode("insert")
else {
// An empty native paste selects and opens the custom answer
// without inserting a character or submitting another option.
context.renderer.keyInput.emit("paste", new PasteEvent(new Uint8Array()))
}
return true
}
const arrows: Record<string, [string, string]> = {
h: ["left", "\x1b[D"], j: ["down", "\x1b[B"],
k: ["up", "\x1b[A"], l: ["right", "\x1b[C"],
}
const arrow = arrows[key]
if (arrow) {
consume(event)
sendKey(...arrow)
return true
}
if (key === "<CR>" && !input) waitForEditor()
if (/^[1-9]$/.test(key) || key === "<Space>") {
consume(event)
// Keep the native selection shortcut, without triggering the
// custom row's printable-character interceptor.
sendKey(event.name, "")
if (!input) waitForEditor()
} else if (event.sequence && !/[\p{C}]/u.test(event.sequence) && key !== "<CR>" && key !== "<Tab>") {
consume(event)
}
return true
}

return { handle, focus, paste }
}
7 changes: 4 additions & 3 deletions src/modules/vim/edit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { WidthMethod } from "@opentui/core"
import { charToDisplay } from "./map"

const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
Expand All @@ -10,7 +11,7 @@ type Input = {
clearSelection: () => unknown
}

export function editInput(input: Input, value: string) {
export function editInput(input: Input, value: string, widthMethod: WidthMethod) {
const before = input.plainText
if (before === value) return
input.clearSelection()
Expand Down Expand Up @@ -41,10 +42,10 @@ export function editInput(input: Input, value: string) {
valueEnd += extra
}

const startOffset = charToDisplay(before, start)
const startOffset = charToDisplay(before, start, widthMethod)

if (start === end) input.cursorOffset = startOffset
else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start))
else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start, widthMethod))
input.insertText(value.slice(start, valueEnd))
}

Expand Down
2 changes: 1 addition & 1 deletion view.tsx → src/modules/vim/status.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import type { TextRenderable } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { VimMode } from "./src/modules/vim/state"
import type { VimMode } from "./state"

type VimStatusProps = {
mode: Accessor<VimMode>
Expand Down
Loading
Loading