From cedf6496414080d92f2baca1ccbb73bdd3d9f4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrey=20Farias=20de=20Ara=C3=BAjo?= Date: Mon, 28 Sep 2026 09:23:01 -0300 Subject: [PATCH 01/10] fix: prevent tab freezes from reactive focus callbacks --- test/plugin.test.tsx | 37 +++++++++++++++++++++++++++++++++++-- tui.tsx | 8 +++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/test/plugin.test.tsx b/test/plugin.test.tsx index 06bf686..b3c0103 100644 --- a/test/plugin.test.tsx +++ b/test/plugin.test.tsx @@ -5,7 +5,7 @@ import { createTestRenderer } from "@opentui/core/testing" import { render, useTerminalDimensions, type JSX } from "@opentui/solid" import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" import { Plugin } from "@opencode/plugin/tui" -import { createSignal, For, Show } from "solid-js" +import { createEffect, createRoot, createSignal, For, Show } from "solid-js" import type { VimOptions } from "../src/modules/vim/config" import { createClipboardFixture } from "./clipboard-fixture" @@ -249,7 +249,7 @@ async function mount(options: VimOptions = {}, session?: { messages: TestMessage setCommands([{ id: "prompt.submit" }]) dialogReturnInput?.focus() } - return { ...screen, renderOnce, input, toggle, setSuccess, setPending, openDialog, closeDialog, dispatched, hostMode, setRoute, + return { ...screen, renderOnce, input, toggle, setSuccess, setPending, openDialog, closeDialog, dispatched, hostMode, setRoute, setCommands, setMessages, copied, clipboard, dialog, dialogOptions, clearModal: () => context.ui.dialog.clear(), replaceModal: () => context.ui.dialog.show(() => Another dialog), transcript: () => transcript!, async scrollTranscript(position: number | "bottom") { @@ -267,6 +267,39 @@ async function mount(options: VimOptions = {}, session?: { messages: TestMessage unmount: () => setMounted(false) } } +test("focus callbacks do not subscribe host tab effects to Vim or keymap state", async () => { + const f = await mount({ defaultMode: "insert" }) + const other = new TextareaRenderable(f.renderer, { id: "other-tab", height: 3, width: 40 }) + f.renderer.root.add(other) + const [tab, setTab] = createSignal(other) + let focusRuns = 0 + const stop = createRoot((dispose) => { + createEffect(() => { + focusRuns++ + tab().focus() + }) + return dispose + }) + try { + expect(f.renderer.currentFocusedEditor).toBe(other) + expect(focusRuns).toBe(1) + f.setCommands([{ id: "prompt.submit" }, { id: "prompt.history.previous" }]) + expect(focusRuns).toBe(1) + f.mockInput.pressEscape() + await f.renderOnce() + expect(other.cursorStyle.style).toBe("block") + expect(focusRuns).toBe(1) + setTab(f.input) + expect(f.renderer.currentFocusedEditor).toBe(f.input) + expect(focusRuns).toBe(2) + await f.keys("i") + expect(f.input.cursorStyle.style).toBe("line") + expect(focusRuns).toBe(2) + } finally { + stop() + } +}) + test("plugin intercepts keys before an already-focused textarea", async () => { const f = await mount() f.mockInput.pressKey("x") diff --git a/tui.tsx b/tui.tsx index 8e2b4db..16bc7db 100644 --- a/tui.tsx +++ b/tui.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { Plugin } from "@opencode/plugin/tui" import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" -import { createEffect, createSignal, onCleanup } from "solid-js" +import { createEffect, createSignal, onCleanup, untrack } from "solid-js" import { applyVimCursorStyle, focusedInput } from "./src/modules/vim/actions" import { createVimConfig } from "./src/modules/vim/config" import { editInput } from "./src/modules/vim/edit" @@ -163,12 +163,14 @@ function VimHost(props: { context: Context }) { if (consumed) syncCursor(true) } - const onFocus = () => { + // Focus events can fire inside the host's prompt effects. Do not subscribe + // those effects to Vim state or the keymap registry while syncing the editor. + const onFocus = () => untrack(() => { pendingKeys = undefined vimee.suspend() dialogVimee.suspend() syncCursor() - } + }) const onPaste = (event: PasteEvent) => { if (!pendingKeys || event.defaultPrevented) return pendingKeys.push(new PasteEvent(event.bytes, event.metadata)) From db3de6c1702200af6844adc0d33e3000b23ca934 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 10:46:01 -0400 Subject: [PATCH 02/10] Remove message reader line counter --- src/session.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/session.tsx b/src/session.tsx index 0b81363..79a2e31 100644 --- a/src/session.tsx +++ b/src/session.tsx @@ -194,7 +194,6 @@ function MessageReader(props: { const theme = () => context.theme.surface("dialog") const background = () => theme().background.raised.high const state = createVimState("normal") - const [line, setLine] = createSignal(1) let input!: TextareaRenderable const adapter = createVimeeAdapter(state, props.config, () => {}, { readOnly: true, onYank: props.copy }) const editorContext: PromptContext = { @@ -232,12 +231,10 @@ function MessageReader(props: { const rows = Math.max(1, Math.floor(input.height / (half ? 2 : 1))) for (let row = 0; row < rows; row++) adapter.handle({ ...event, ctrl: false } as KeyEvent, down ? "j" : "k", editorContext) } else adapter.handle(event, key, editorContext) - setLine(input.logicalCursor.row + 1) } onMount(() => { input.cursorOffset = Math.min(props.offset, displayWidth(input.plainText)) adapter.attach(editorContext) - setLine(input.logicalCursor.row + 1) input.focus() context.renderer.keyInput.prependListener("keypress", onKey) }) @@ -268,7 +265,7 @@ function MessageReader(props: { backgroundColor={background()} focusedBackgroundColor={background()} focusedTextColor={theme().text.base} /> - {modeLabel()} · {line()}/{input?.lineCount ?? 1} + {modeLabel()} {props.notice() || (state.mode() !== "normal" ? "y copy · Esc cancel" : `v select · V lines · ${props.config.sessionKey} prompt`)} From 2ff20893afb8acc28a7c2ba5afe9843bfcc04361 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 10:56:43 -0400 Subject: [PATCH 03/10] Add required CI checks for pull requests to main --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..35edea2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck and tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Neovim for reference tests + run: | + sudo apt-get update + sudo apt-get install -y neovim + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: bun run typecheck + + - name: Test source and npm package + run: bun run test From 37af3b30c02a279c51dc9bc86f7e5f1c6c593d71 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:00:08 -0400 Subject: [PATCH 04/10] Skip CI tests for draft pull requests --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35edea2..52fc2a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,12 @@ on: pull_request: branches: - main + types: + - opened + - synchronize + - reopened + - ready_for_review + - converted_to_draft permissions: contents: read @@ -15,6 +21,7 @@ concurrency: jobs: verify: name: Typecheck and tests + if: github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 10 From 280638cacac5a1767a7ff7b005e837cb7888f2d1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:03:47 -0400 Subject: [PATCH 05/10] Pin Neovim version for CI reference tests --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52fc2a7..cbb82a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,9 @@ jobs: - name: Install Neovim for reference tests run: | - sudo apt-get update - sudo apt-get install -y neovim + curl -fsSL https://github.com/neovim/neovim/releases/download/v0.12.5/nvim-linux-x86_64.tar.gz -o "$RUNNER_TEMP/nvim.tar.gz" + tar -xzf "$RUNNER_TEMP/nvim.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/nvim-linux-x86_64/bin" >> "$GITHUB_PATH" - name: Install dependencies run: npm ci From d7e44b3203d4016bb2504b794d5e8e341b77c429 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:09:44 -0400 Subject: [PATCH 06/10] Use pinned Neovim for local and CI tests --- .github/workflows/ci.yml | 6 ------ src/modules/vim/vimee.ts | 2 +- test/neovim.ts | 43 ++++++++++++++++++++++++++++++++++++++++ test/reference.test.ts | 12 +++++------ 4 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 test/neovim.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbb82a9..146a9ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,12 +37,6 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 - - name: Install Neovim for reference tests - run: | - curl -fsSL https://github.com/neovim/neovim/releases/download/v0.12.5/nvim-linux-x86_64.tar.gz -o "$RUNNER_TEMP/nvim.tar.gz" - tar -xzf "$RUNNER_TEMP/nvim.tar.gz" -C "$RUNNER_TEMP" - echo "$RUNNER_TEMP/nvim-linux-x86_64/bin" >> "$GITHUB_PATH" - - name: Install dependencies run: npm ci diff --git a/src/modules/vim/vimee.ts b/src/modules/vim/vimee.ts index f77cff2..68195fa 100644 --- a/src/modules/vim/vimee.ts +++ b/src/modules/vim/vimee.ts @@ -143,7 +143,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL const actions = applyKeybind(resolved.definition, ctx, map) result = { newCtx: vim, actions } } else { - // Match LazyVim's uncounted j/k and arrow mappings in normal/visual mode. + // Use screen-row movement for uncounted j/k and arrows in normal/visual mode. // Apply this at the input boundary so custom mapping sequences stay literal. if (!event.ctrl && vim.phase === "idle" && vim.count === 0 && (commandKey === "j" || commandKey === "k" || commandKey === "ArrowDown" || commandKey === "ArrowUp")) { vim = processKey("g").newCtx diff --git a/test/neovim.ts b/test/neovim.ts new file mode 100644 index 0000000..49d1092 --- /dev/null +++ b/test/neovim.ts @@ -0,0 +1,43 @@ +import { mkdir, mkdtemp, rename, rm } from "node:fs/promises" +import path from "node:path" + +const version = "0.12.5" +const platforms: Record = { linux: "linux", darwin: "macos" } +const architectures: Record = { x64: "x86_64", arm64: "arm64" } +const platform = platforms[process.platform] +const architecture = architectures[process.arch] +if (!platform || !architecture) { + throw new Error(`Neovim reference tests do not support ${process.platform}/${process.arch}`) +} + +const asset = `nvim-${platform}-${architecture}` +const cache = path.resolve(import.meta.dir, "../node_modules/.cache/neovim") +const directory = path.join(cache, `v${version}-${platform}-${architecture}`) +export const nvim = path.join(directory, "bin/nvim") + +if (!await Bun.file(nvim).exists()) { + console.log(`Downloading Neovim v${version} for reference tests`) + await mkdir(cache, { recursive: true }) + const temporary = await mkdtemp(path.join(cache, "download-")) + try { + const url = `https://github.com/neovim/neovim/releases/download/v${version}/${asset}.tar.gz` + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }) + if (!response.ok) { + throw new Error(`Could not download Neovim v${version}: ${response.status} ${response.statusText}`) + } + const archive = path.join(temporary, "nvim.tar.gz") + await Bun.write(archive, response) + const result = Bun.spawnSync(["tar", "-xzf", archive, "-C", temporary]) + if (result.exitCode !== 0) { + throw new Error(`Could not extract Neovim v${version}: ${result.stderr.toString()}`) + } + try { + await rename(path.join(temporary, asset), directory) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== "EEXIST" && code !== "ENOTEMPTY") throw error + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} diff --git a/test/reference.test.ts b/test/reference.test.ts index 6d9a414..b0f2bdb 100644 --- a/test/reference.test.ts +++ b/test/reference.test.ts @@ -1,9 +1,7 @@ import { expect, test } from "bun:test" import { createFixture } from "./fixture" +import { nvim } from "./neovim" -// Neovim is an optional external oracle; the real-textarea regression tests -// remain runnable on machines without it. Never load the user's Vim config. -const nvim = Bun.which("nvim") const cases: Array<[string, string, number?]> = [ ["one two three", "dw"], ["one two three", "2dw"], @@ -121,10 +119,10 @@ const cases: Array<[string, string, number?]> = [ ] for (const [text, keys, width = 80] of cases) { - test.skipIf(!nvim)(`Neovim parity with LazyVim motions (${width} columns): ${JSON.stringify(text)} ${keys}`, async () => { + test(`Neovim parity (${width} columns): ${JSON.stringify(text)} ${keys}`, async () => { // Match Vim's startofline default (Neovim disables it), and the host's - // word wrapping and two-column tabs. Add only LazyVim's vertical mappings; - // neither editor loads user settings. + // word wrapping and two-column tabs. Use screen-row movement for uncounted + // vertical motions; neither editor loads user settings. const mappings = ` vim.keymap.set({'n', 'x'}, 'j', "v:count == 0 ? 'gj' : 'j'", {expr=true}) vim.keymap.set({'n', 'x'}, 'k', "v:count == 0 ? 'gk' : 'k'", {expr=true}) @@ -132,7 +130,7 @@ for (const [text, keys, width = 80] of cases) { vim.keymap.set({'n', 'x'}, '', "v:count == 0 ? 'gk' : 'k'", {expr=true}) ` const lua = `${mappings}\nvim.api.nvim_open_win(0,true,{relative='editor',row=0,col=0,width=${width},height=10,style='minimal'}); vim.o.startofline=true; vim.wo.wrap=true; vim.wo.linebreak=true; vim.bo.tabstop=2; vim.bo.shiftwidth=2; vim.bo.expandtab=true; vim.api.nvim_buf_set_lines(0,0,-1,false,vim.json.decode(${JSON.stringify(JSON.stringify(text.split("\n")))})); vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(${JSON.stringify(keys)},true,false,true),'xt',false); io.write(vim.json.encode({text=table.concat(vim.api.nvim_buf_get_lines(0,0,-1,false),'\\n'),col=vim.api.nvim_win_get_cursor(0)[2],row=vim.api.nvim_win_get_cursor(0)[1]}))` - const result = Bun.spawnSync([nvim!, "--headless", "-u", "NONE", "-i", "NONE", "-n", "-c", `lua ${lua}`, "-c", "qa!"]) + const result = Bun.spawnSync([nvim, "--headless", "-u", "NONE", "-i", "NONE", "-n", "-c", `lua ${lua}`, "-c", "qa!"]) expect(result.exitCode).toBe(0) const reference = JSON.parse(result.stdout.toString()) as { text: string; col: number; row: number } const fixture = await createFixture(text, {}, width) From 9401a0fd48e0f9e3900ab12cc399b3b9cbea9ad5 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:32:01 -0400 Subject: [PATCH 07/10] Add modular real-terminal E2E tests --- .gitignore | 1 + package.json | 1 + test/e2e/fixture.ts | 138 ++++++++++++++++++++++++++++ test/e2e/opencode.ts | 45 +++++++++ test/e2e/plugin.ts | 18 ++++ test/e2e/run.ts | 63 +++++++++++++ test/e2e/scenarios/dialog-focus.ts | 40 ++++++++ test/e2e/scenarios/tab-switching.ts | 22 +++++ test/e2e/terminal.ts | 59 ++++++++++++ 9 files changed, 387 insertions(+) create mode 100644 test/e2e/fixture.ts create mode 100644 test/e2e/opencode.ts create mode 100644 test/e2e/plugin.ts create mode 100644 test/e2e/run.ts create mode 100644 test/e2e/scenarios/dialog-focus.ts create mode 100644 test/e2e/scenarios/tab-switching.ts create mode 100644 test/e2e/terminal.ts diff --git a/.gitignore b/.gitignore index 6134f6e..7ae8be6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +test-results/ dist/ .opencode/ docs/* diff --git a/package.json b/package.json index 9983554..8a40875 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prepack": "bun run build", "typecheck": "tsc --noEmit", "test": "bun test --conditions=browser --preload @opentui/solid/preload", + "test:e2e": "bun test/e2e/run.ts", "bench": "bun test/benchmark.ts" }, "dependencies": { diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts new file mode 100644 index 0000000..fde95e5 --- /dev/null +++ b/test/e2e/fixture.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { cp, mkdir } from "node:fs/promises" +import { createServer } from "node:net" +import path from "node:path" +import { createTerminal, type Terminal } from "./terminal" + +export type Fixture = { terminal: Terminal; sessionTitle: string } + +type Options = { + opencode: { binary: string; version: string } + plugin: string + directory: string + artifacts: string +} + +export async function runWithFixture(options: Options, run: (fixture: Fixture) => Promise) { + const { opencode, plugin, directory, artifacts } = options + const workspace = path.join(directory, "workspace") + const password = crypto.randomUUID() + const sessionTitle = "E2E existing session" + const terminal = createTerminal(path.join(directory, "tmux.sock"), artifacts) + let server: Bun.Subprocess | undefined + let url = "" + + function stop() { + terminal.stop() + if (server) { + try { + process.kill(-server.pid, "SIGKILL") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error + } + } + } + + function interrupt() { stop(); process.exit(130) } + function terminate() { stop(); process.exit(143) } + process.once("SIGINT", interrupt) + process.once("SIGTERM", terminate) + + try { + await mkdir(artifacts, { recursive: true }) + await mkdir(workspace, { recursive: true }) + for (const home of ["server", "tui"]) { + await mkdir(path.join(directory, home, "config"), { recursive: true }) + } + assert.equal(execFileSync(opencode.binary, ["--version"], { env: environment("server"), encoding: "utf8" }).trim(), `opencode v${opencode.version}`) + await Bun.write(path.join(directory, "tui/config/cli.json"), JSON.stringify({ + plugins: [plugin], + tabs: { mode: "on" }, + attention: { notifications: false, sound: false }, + })) + + const listener = createServer() + await new Promise((resolve, reject) => { + listener.once("error", reject) + listener.listen(0, "127.0.0.1", resolve) + }) + const address = listener.address() + assert(address && typeof address !== "string") + const port = address.port + await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve())) + url = `http://127.0.0.1:${port}` + server = Bun.spawn([opencode.binary, "serve", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: workspace, + env: environment("server"), + detached: true, + stdout: Bun.file(path.join(artifacts, "server.stdout.log")), + stderr: Bun.file(path.join(artifacts, "server.stderr.log")), + }) + const deadline = Date.now() + 60_000 + while (true) { + if (server.exitCode !== null) throw new Error(`OpenCode server exited: ${server.exitCode}`) + try { + await request("/api/session") + break + } catch (error) { + if (Date.now() >= deadline) throw error + await Bun.sleep(100) + } + } + const session = (await request("/api/session", { title: sessionTitle, location: { directory: workspace } })).data + const command = ["env", "-i"] + for (const [key, value] of Object.entries(environment("tui"))) command.push(`${key}=${value}`) + command.push(opencode.binary, "--server", url, "--session", session.id, workspace) + terminal.start(command, workspace) + await terminal.screen("startup", (text) => text.includes(sessionTitle) && text.includes("INSERT"), 60_000) + + await run({ terminal, sessionTitle }) + assert.equal((await request(`/api/session/${session.id}`)).data.id, session.id) + } finally { + process.off("SIGINT", interrupt) + process.off("SIGTERM", terminate) + stop() + if (server) await server.exited + for (const home of ["server", "tui"]) { + const log = path.join(directory, home, "data/opencode/log") + await cp(log, path.join(artifacts, `${home}-logs`), { recursive: true }).catch((error) => { + if (error.code !== "ENOENT") throw error + }) + } + } + + function environment(home: string): Record { + const directory = path.join(options.directory, home) + return { + PATH: process.env.PATH!, + HOME: directory, + SHELL: "/bin/sh", + LANG: "en_US.UTF-8", + TERM: "tmux-256color", + COLORTERM: "truecolor", + OPENCODE_TEST_HOME: directory, + OPENCODE_CONFIG_DIR: path.join(directory, "config"), + OPENCODE_CONFIG_CONTENT: "{}", + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_DISABLE_FILEWATCHER: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_PASSWORD: password, + XDG_CONFIG_HOME: path.join(directory, "xdg-config"), + XDG_DATA_HOME: path.join(directory, "data"), + XDG_CACHE_HOME: path.join(directory, "cache"), + XDG_STATE_HOME: path.join(directory, "state"), + } + } + + async function request(endpoint: string, body?: unknown) { + const response = await fetch(url + endpoint, { + method: body === undefined ? "GET" : "POST", + headers: { authorization: `Basic ${btoa(`opencode:${password}`)}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(3_000), + }) + if (!response.ok) throw new Error(`${endpoint}: ${response.status} ${await response.text()}`) + return response.json() + } +} diff --git a/test/e2e/opencode.ts b/test/e2e/opencode.ts new file mode 100644 index 0000000..85d4782 --- /dev/null +++ b/test/e2e/opencode.ts @@ -0,0 +1,45 @@ +import { mkdir, mkdtemp, rename, rm } from "node:fs/promises" +import { execFileSync } from "node:child_process" +import path from "node:path" + +export async function installOpenCode() { + if ((process.platform !== "linux" && process.platform !== "darwin") || (process.arch !== "x64" && process.arch !== "arm64")) { + throw new Error(`OpenCode E2E tests do not support ${process.platform}/${process.arch}`) + } + const release = await fetch("https://opencode.ai/update/api/latest/cli/npm", { signal: AbortSignal.timeout(30_000) }) + if (!release.ok) throw new Error(`Could not resolve the latest OpenCode release: ${release.status}`) + const { version } = await release.json() as { version: string } + if (!/^2\.\d+\.\d+$/.test(version)) throw new Error(`Expected a stable OpenCode V2 release, received ${version}`) + console.log(`OpenCode E2E version: ${version} (latest stable)`) + const architecture = process.arch === "x64" ? "x64-baseline" : "arm64" + const target = `${process.platform}-${architecture}` + const cache = path.resolve(import.meta.dir, "../../node_modules/.cache/opencode") + const directory = path.join(cache, `${version}-${target}`) + const binary = path.join(directory, "opencode") + if (await Bun.file(binary).exists()) return { binary, version } + + console.log(`Downloading OpenCode ${version} for E2E tests`) + await mkdir(cache, { recursive: true }) + const temporary = await mkdtemp(path.join(cache, "download-")) + try { + const extension = process.platform === "darwin" ? "zip" : "tar.gz" + const archive = path.join(temporary, `opencode.${extension}`) + const response = await fetch(`https://opencode.ai/files/bin/${version}/opencode-${target}.${extension}`, { + signal: AbortSignal.timeout(120_000), + }) + if (!response.ok) throw new Error(`Could not download OpenCode: ${response.status} ${response.statusText}`) + await Bun.write(archive, response) + if (extension === "zip") execFileSync("unzip", ["-q", archive, "-d", temporary]) + else execFileSync("tar", ["-xzf", archive, "-C", temporary]) + await rm(archive) + try { + await rename(temporary, directory) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== "EEXIST" && code !== "ENOTEMPTY") throw error + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } + return { binary, version } +} diff --git a/test/e2e/plugin.ts b/test/e2e/plugin.ts new file mode 100644 index 0000000..aa79ecd --- /dev/null +++ b/test/e2e/plugin.ts @@ -0,0 +1,18 @@ +import { execFileSync } from "node:child_process" +import { mkdir, symlink } from "node:fs/promises" +import path from "node:path" + +export async function packPlugin(root: string, directory: string, artifacts: string) { + const manifest = await Bun.file(path.join(root, "package.json")).json() + const packed = execFileSync("npm", ["pack", "--quiet", "--pack-destination", directory], { + cwd: root, + encoding: "utf8", + timeout: 120_000, + }) + await Bun.write(path.join(artifacts, "pack.log"), packed) + const plugin = path.join(directory, "node_modules", manifest.name) + await mkdir(plugin, { recursive: true }) + execFileSync("tar", ["-xzf", path.join(directory, `${manifest.name}-${manifest.version}.tgz`), "--strip-components=1", "-C", plugin]) + await symlink(path.join(root, "node_modules"), path.join(plugin, "node_modules"), "dir") + return path.join(plugin, "dist") +} diff --git a/test/e2e/run.ts b/test/e2e/run.ts new file mode 100644 index 0000000..c0d6562 --- /dev/null +++ b/test/e2e/run.ts @@ -0,0 +1,63 @@ +import { execFileSync } from "node:child_process" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import { runWithFixture } from "./fixture" +import { installOpenCode } from "./opencode" +import { packPlugin } from "./plugin" +import { dialogFocus } from "./scenarios/dialog-focus" +import { tabSwitching } from "./scenarios/tab-switching" + +const scenarios = [ + { name: "tab-switching", run: tabSwitching }, + { name: "dialog-focus", run: dialogFocus }, +] + +if (!Bun.which("tmux")) throw new Error("E2E tests require tmux. Install it, then run bun run test:e2e.") + +const root = path.resolve(import.meta.dir, "../..") +const output = path.join(root, "test-results/e2e") +await mkdir(output, { recursive: true }) +const artifacts = await mkdtemp(path.join(output, "run-")) +await mkdir("/tmp/opencode", { recursive: true }) +const temporary = await mkdtemp("/tmp/opencode/vim-e2e-") +const results: Array<{ name: string; status: "passed" | "failed"; milliseconds: number; error?: string }> = [] +let version: string | undefined +let setupFailure: string | undefined + +console.log(`E2E artifacts: ${artifacts}`) +try { + const opencode = await installOpenCode() + version = opencode.version + const plugin = await packPlugin(root, temporary, artifacts) + + for (const scenario of scenarios) { + const started = Date.now() + try { + await runWithFixture({ + opencode, + plugin, + directory: path.join(temporary, scenario.name), + artifacts: path.join(artifacts, scenario.name), + }, scenario.run) + results.push({ name: scenario.name, status: "passed", milliseconds: Date.now() - started }) + console.log(`PASS: ${scenario.name}`) + } catch (error) { + results.push({ name: scenario.name, status: "failed", milliseconds: Date.now() - started, error: String(error) }) + console.error(`FAIL: ${scenario.name}`, error) + process.exitCode = 1 + } + } +} catch (error) { + setupFailure = String(error) + console.error("FAIL: setup", error) + process.exitCode = 1 +} finally { + await Bun.write(path.join(artifacts, "result.json"), JSON.stringify({ + opencode: version, + bun: Bun.version, + tmux: execFileSync("tmux", ["-V"], { encoding: "utf8" }).trim(), + scenarios: results, + setupFailure, + }, null, 2)) + await rm(temporary, { recursive: true, force: true }) +} diff --git a/test/e2e/scenarios/dialog-focus.ts b/test/e2e/scenarios/dialog-focus.ts new file mode 100644 index 0000000..423a3c8 --- /dev/null +++ b/test/e2e/scenarios/dialog-focus.ts @@ -0,0 +1,40 @@ +import type { Fixture } from "../fixture" + +export async function dialogFocus({ terminal }: Fixture) { + const { keys, type, screen } = terminal + for (const mode of ["INSERT", "NORMAL"]) { + const label = `dialog-${mode.toLowerCase()}` + await type("original draft") + await screen(`${label}-draft`, (text) => text.includes("original draft")) + if (mode === "NORMAL") await keys("Escape") + await screen(`${label}-prompt-mode`, (text) => text.includes(mode)) + await keys("C-p") + await screen(`${label}-opened`, (text) => text.includes("Commands") && text.includes(mode)) + if (mode === "NORMAL") { + await type("i") + await screen(`${label}-insert`, (text) => text.includes("Commands") && text.includes("INSERT")) + } + await type("Open settings") + await screen(`${label}-filtered`, (text) => text.includes("Open settings") && !text.includes("New session")) + await keys("Escape") + await screen(`${label}-normal`, (text) => text.includes("Commands") && text.includes("NORMAL")) + await type("0dw") + await screen(`${label}-edited`, (text) => /^\s+settings\s*$/m.test(text) && text.includes("Open settings")) + await keys("Escape") + await screen(`${label}-closed`, (text) => !text.includes("Commands") && text.includes("original draft") && text.includes(mode)) + if (mode === "NORMAL") { + await type("A") + await screen(`${label}-resume-insert`, (text) => text.includes("INSERT")) + } + await type(" restored") + await screen(`${label}-restored`, (text) => text.includes("original draft restored")) + await keys("Escape") + await screen(`${label}-prompt-normal`, (text) => text.includes("NORMAL")) + await type("0dw") + await screen(`${label}-prompt-edited`, (text) => text.includes("draft restored") && !text.includes("original")) + await type("i") + await screen(`${label}-prompt-insert`, (text) => text.includes("INSERT")) + await keys("C-c") + await screen(`${label}-cleared`, (text) => !text.includes("draft restored")) + } +} diff --git a/test/e2e/scenarios/tab-switching.ts b/test/e2e/scenarios/tab-switching.ts new file mode 100644 index 0000000..b2c66bc --- /dev/null +++ b/test/e2e/scenarios/tab-switching.ts @@ -0,0 +1,22 @@ +import type { Fixture } from "../fixture" + +export async function tabSwitching({ terminal, sessionTitle }: Fixture) { + const { keys, type, screen } = terminal + for (let cycle = 1; cycle <= 10; cycle++) { + const label = `tab-${String(cycle).padStart(2, "0")}` + await keys("C-x", "n") + await screen(`${label}-new`, (text) => text.split("\n")[0].includes("New session") && text.includes("INSERT")) + await type(`alpha beta gamma cycle${cycle}`) + await screen(`${label}-typed`, (text) => text.includes(`alpha beta gamma cycle${cycle}`)) + await keys("Escape") + await screen(`${label}-normal`, (text) => text.includes("NORMAL")) + await type("0dw") + await screen(`${label}-edited`, (text) => text.includes(`beta gamma cycle${cycle}`) && !text.includes("alpha")) + await type("i") + await screen(`${label}-insert`, (text) => text.includes("INSERT")) + await keys("C-c") + await screen(`${label}-cleared`, (text) => !text.includes(`beta gamma cycle${cycle}`) && text.includes("INSERT")) + await keys("C-x", "1") + await screen(`${label}-returned`, (text) => text.includes(sessionTitle) && !text.split("\n")[0].includes("New session")) + } +} diff --git a/test/e2e/terminal.ts b/test/e2e/terminal.ts new file mode 100644 index 0000000..b233077 --- /dev/null +++ b/test/e2e/terminal.ts @@ -0,0 +1,59 @@ +import { execFileSync, spawnSync } from "node:child_process" +import { appendFile } from "node:fs/promises" +import path from "node:path" + +export type Terminal = ReturnType + +export function createTerminal(socket: string, artifacts: string) { + let pid: number | undefined + + function tmux(...args: string[]) { + return execFileSync("tmux", ["-S", socket, ...args], { encoding: "utf8", timeout: 5_000 }) + } + + function start(command: string[], workspace: string) { + pid = Number(tmux("-u", "-f", "/dev/null", "new-session", "-d", "-s", "e2e", "-x", "120", "-y", "38", "-c", workspace, + "-P", "-F", "#{pane_pid}", ...command).trim()) + tmux("set-option", "-t", "e2e", "remain-on-exit", "on") + } + + function stop() { + if (pid) { + try { + process.kill(-pid, "SIGKILL") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error + } + } + spawnSync("tmux", ["-S", socket, "kill-server"], { timeout: 5_000, stdio: "ignore" }) + } + + async function keys(...keys: string[]) { + await appendFile(path.join(artifacts, "keys.log"), `${Date.now()} ${keys.join(" ")}\n`) + tmux("send-keys", "-t", "e2e", ...keys) + } + + async function type(text: string) { + await keys("-l", text) + } + + async function screen(label: string, matches: (text: string) => boolean, timeout = 5_000) { + const deadline = Date.now() + timeout + while (true) { + const text = tmux("capture-pane", "-p", "-t", "e2e") + const matched = matches(text) + if (matched || Date.now() >= deadline) { + await Bun.write(path.join(artifacts, `${label}.txt`), text) + if (!matched) throw new Error(`Timed out waiting for ${label}; see ${artifacts}/${label}.txt`) + return + } + if (tmux("display-message", "-p", "-t", "e2e", "#{pane_dead}").trim() === "1") { + await Bun.write(path.join(artifacts, `${label}.txt`), text) + throw new Error(`OpenCode exited while waiting for ${label}`) + } + await Bun.sleep(100) + } + } + + return { start, stop, keys, type, screen } +} From 4dd09dce76f34871a483a193d91a1ff9f1e1340a Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:32:01 -0400 Subject: [PATCH 08/10] Run E2E tests in pull request CI --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 146a9ee..a5646fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,40 @@ jobs: - name: Test source and npm package run: bun run test + + e2e: + name: E2E tests + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install tmux + run: | + sudo apt-get update + sudo apt-get install -y tmux + + - name: Install dependencies + run: npm ci + + - name: Test in OpenCode + run: bun run test:e2e + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-diagnostics + path: test-results/e2e/ + retention-days: 7 From 88055675e87d81ac7bdba34bc9d627457635c626 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:54:52 -0400 Subject: [PATCH 09/10] Fix Shift+Tab agent switching across Vim modes --- src/session.tsx | 6 ++++ test/e2e/run.ts | 2 ++ test/e2e/scenarios/agent-switching.ts | 29 +++++++++++++++++ test/plugin.test.tsx | 45 ++++++++++++++++++++++++++- tui.tsx | 1 + 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 test/e2e/scenarios/agent-switching.ts diff --git a/src/session.tsx b/src/session.tsx index 79a2e31..cc40db6 100644 --- a/src/session.tsx +++ b/src/session.tsx @@ -33,6 +33,11 @@ export function createSessionMode(context: Context, config: VimConfig, clipboard let yankTimer: ReturnType | undefined let yankID: string | undefined + context.keymap.layer(() => ({ + mode: SESSION_MODE, + bindings: ["agent.cycle", "agent.cycle.reverse"], + })) + function selected() { return transcript?.get(selectedID) } @@ -278,6 +283,7 @@ function readerKey(context: Context, event: KeyEvent, sessionKey: string, mode = if (event.defaultPrevented || context.keymap.mode.current() !== mode) return if (context.keymap.pending().length || event.super || event.meta) return const key = keyNotation(event) + if (key === "" && event.shift) return if (key && (key === sessionKey || !event.ctrl || key === "" || pageCommand(key) || (mode === "modal" && key === ""))) return key } diff --git a/test/e2e/run.ts b/test/e2e/run.ts index c0d6562..74b39e7 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -4,12 +4,14 @@ import path from "node:path" import { runWithFixture } from "./fixture" import { installOpenCode } from "./opencode" import { packPlugin } from "./plugin" +import { agentSwitching } from "./scenarios/agent-switching" import { dialogFocus } from "./scenarios/dialog-focus" import { tabSwitching } from "./scenarios/tab-switching" const scenarios = [ { name: "tab-switching", run: tabSwitching }, { name: "dialog-focus", run: dialogFocus }, + { name: "agent-switching", run: agentSwitching }, ] if (!Bun.which("tmux")) throw new Error("E2E tests require tmux. Install it, then run bun run test:e2e.") diff --git a/test/e2e/scenarios/agent-switching.ts b/test/e2e/scenarios/agent-switching.ts new file mode 100644 index 0000000..a340ec3 --- /dev/null +++ b/test/e2e/scenarios/agent-switching.ts @@ -0,0 +1,29 @@ +import type { Fixture } from "../fixture" + +export async function agentSwitching({ terminal }: Fixture) { + const { keys, type, screen } = terminal + const draft = "agent switching draft" + let agent = "" + await type(draft) + await screen("draft", (text) => { + agent = /┃\s+(Build|Plan) ·/.exec(text)?.[1] ?? "" + return text.includes(draft) && !!agent + }) + await keys("Escape") + await screen("initial-normal", (text) => text.includes("NORMAL")) + + for (const [mode, sequence] of [ + ["INSERT", "i"], ["NORMAL", ""], ["VISUAL", "0vl"], ["VISUAL LINE", "V"], ["SESSION", "s"], + ]) { + for (let cycle = 1; cycle <= 2; cycle++) { + const label = `${mode.toLowerCase().replaceAll(" ", "-")}-${cycle}` + if (sequence) await type(sequence) + await screen(`${label}-before`, (text) => text.includes(mode) && text.includes(draft)) + await keys("BTab") + agent = agent === "Build" ? "Plan" : "Build" + await screen(`${label}-after`, (text) => text.includes(`${agent} ·`) && text.includes(mode) && text.includes(draft)) + if (mode !== "NORMAL") await keys("Escape") + await screen(`${label}-prompt`, (text) => text.includes("NORMAL") && text.includes(draft)) + } + } +} diff --git a/test/plugin.test.tsx b/test/plugin.test.tsx index b3c0103..b64fcc3 100644 --- a/test/plugin.test.tsx +++ b/test/plugin.test.tsx @@ -117,7 +117,10 @@ async function mount(options: VimOptions = {}, session?: { messages: TestMessage else return targetID = navigationID }, - layer(fn: () => { commands: Array<{ run: () => void }> }) { toggle = fn().commands[0].run }, + layer(fn: () => { commands?: Array<{ run: () => void }> }) { + const command = fn().commands?.[0] + if (command) toggle = command.run + }, }, data: { session: { message: { list: messages } } }, ui: { @@ -561,6 +564,46 @@ test("host shortcuts and pending leader sequences reach the host", async () => { expect(f.input.plainText).toBe("hello") }) +test.each([ + ["normal", ""], ["insert", "i"], ["visual", "vl"], ["visual-line", "V"], ["session", "s"], +])("Shift+Tab reaches the host without changing %s mode or selection", async (_mode, keys) => { + const f = await mount({}, { messages: [message(1, "Answer")] }) + await f.keys(keys) + const frame = f.captureCharFrame() + const offset = f.input.cursorOffset + const selection = f.input.getSelectedText() + const hostMode = f.hostMode() + let received = 0 + f.renderer.keyInput.on("keypress", (event) => { + if (event.name !== "tab" || !event.shift) return + received++ + event.preventDefault() + }) + f.mockInput.pressKey("TAB", { shift: true }) + await f.renderOnce() + expect(received).toBe(1) + expect(f.captureCharFrame()).toBe(frame) + expect(f.hostMode()).toBe(hostMode) + expect(f.input.cursorOffset).toBe(offset) + expect(f.input.getSelectedText()).toBe(selection) + expect(f.input.plainText).toBe("hello") +}) + +test("a plain Tab mapping does not capture Shift+Tab", async () => { + const f = await mount({ keymaps: { normal: { "": "x" } } }) + const received: boolean[] = [] + f.renderer.keyInput.on("keypress", (event) => { + received.push(event.shift) + event.preventDefault() + }) + f.mockInput.pressKey("TAB", { shift: true }) + expect(received).toEqual([true]) + expect(f.input.plainText).toBe("hello") + f.mockInput.pressKey("TAB") + expect(received).toEqual([true]) + expect(f.input.plainText).toBe("ello") +}) + test("unmount removes handlers and restores native editing", async () => { const f = await mount() const listeners = f.renderer.keyInput.listenerCount("keypress") diff --git a/tui.tsx b/tui.tsx index 16bc7db..93f68fd 100644 --- a/tui.tsx +++ b/tui.tsx @@ -313,6 +313,7 @@ function compatTheme(context: Context) { } function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boolean, mapped: boolean) { + if (key === "" && event.shift) return true if (mode !== "normal") return false if (mapped) return false if (event.ctrl && key !== "" && key !== "") return true From 8b443767c683f91661978e5a6458d5b75bf23bea Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 11:56:29 -0400 Subject: [PATCH 10/10] Bump version to 0.0.26 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaaeef9..fe878a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-vim", - "version": "0.0.25", + "version": "0.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-vim", - "version": "0.0.25", + "version": "0.0.26", "dependencies": { "@vimee/core": "^0.3.0" }, diff --git a/package.json b/package.json index 8a40875..5196336 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-vim", - "version": "0.0.25", + "version": "0.0.26", "type": "module", "repository": { "type": "git",