From 801c4e08f4b803c02926cd1ca86ffbf927465856 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 1 Oct 2026 10:12:52 -0400 Subject: [PATCH 1/3] test: speed up isolated e2e runs --- .github/workflows/ci.yml | 28 ++++++++++++ test/README.md | 70 ++++++++++++++++++++---------- test/e2e/run.ts | 82 ++++++++++++++++++++++++++---------- test/e2e/support/colors.ts | 12 +++++- test/e2e/support/fixture.ts | 22 +++++++++- test/e2e/support/terminal.ts | 10 +++-- test/unit/yank-flash.test.ts | 37 ++++++++++++++++ 7 files changed, 210 insertions(+), 51 deletions(-) create mode 100644 test/unit/yank-flash.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f778fd..4e30a8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22.x" + cache: npm - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -40,6 +41,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Cache Neovim reference binary + uses: actions/cache@v4 + with: + path: node_modules/.cache/neovim + key: neovim-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/helpers/neovim.ts') }} + - name: Typecheck run: bun run typecheck @@ -63,6 +70,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22.x" + cache: npm - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -75,9 +83,29 @@ jobs: - name: Install dependencies run: npm ci + - name: Cache E2E binaries + uses: actions/cache@v4 + with: + path: | + node_modules/.cache/opencode + node_modules/.cache/neovim + key: e2e-binaries-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/e2e/support/opencode.ts', 'test/helpers/neovim.ts') }}-${{ github.sha }} + restore-keys: | + e2e-binaries-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/e2e/support/opencode.ts', 'test/helpers/neovim.ts') }}- + - name: Test source and npm package in OpenCode + env: + E2E_WORKERS: "2" run: bun run test:e2e + - name: Upload timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-timings + path: test-results/e2e/*/result.json + retention-days: 7 + - name: Upload failure diagnostics if: failure() uses: actions/upload-artifact@v4 diff --git a/test/README.md b/test/README.md index 4bf39d3..69a7a5e 100644 --- a/test/README.md +++ b/test/README.md @@ -30,29 +30,29 @@ agent bindings and diff view run the full flows; alternate configurations check key routing, mode inheritance, initial views and focus restoration. Invalid configuration fallback stays in unit tests. -| Behavior | Local coverage | Real OpenCode coverage | -| --- | --- | --- | -| Prompt focus, mode/status/theme, mappings, native key passthrough, cleanup | | `prompt-input`, `runtime-*`, `tab-switching`, `agent-switching*` | -| Clipboard Unicode/CRLF/counts, undo/redo, registers, async ordering, fallback, adapter cancellation | `integration/clipboard.test.ts` | `prompt-clipboard`, `session-copy`, `clipboard-cancel-*` (late response and queued keys across focus/route/toggle/unload) | -| Dialog query changes and command selection | | `dialog-focus`, `dialog-focus-normal`, `dialog-mappings`, `dialog-multikey-mappings`, `dialog-scope`, `prompt-dialog` (mode inheritance, filtered results after edit/undo/redo, insert/normal mappings, undo isolation, mapping-prefix priority, native navigation, pending motions and unrelated extension input) | -| Question form navigation, custom answers, insert mappings, submission/cancellation and prompt restoration | | `question-forms`, `question-forms-kj`, `question-forms-zz` | -| Subagent/shell/terminal panel Vim navigation, native shortcuts, disabled-plugin passthrough and prompt restoration | | `composer-navigation` | -| Real PTY passthrough, focus isolation, route/removal/disable/unload and nested Neovim | | `terminal-focus`, `terminal-application` | -| Ctrl+/ terminal creation/focus/hide, Alt+h/l pane focus, modern/legacy encodings, prompt modes/cursor styles and child input isolation | | `terminal-toggle`, `terminal-application` | -| Shared pane command mappings, default overrides, disabled/legacy chords, dynamic footer and released child keys | `unit/pane-keymaps.test.ts` | `terminal-mappings`, `terminal-bindings-disabled`, `terminal-binding-override` | -| Native prompt history, exit on movement, Unicode edits and mapped-command cursor preservation | | `prompt-history`, `prompt-command-cursor` | -| Session entry/exit, toggle key scope, mappings, control chords, narrow status layout | `unit/session-keymaps.test.ts` | `session-key-*`, `session-empty`, `session-keymaps`, `session-agent-binding` | -| Session and reader lifecycle, route changes, replacement dialogs and disable | `integration/readers/text.test.ts` | `session-lifecycle`, `runtime-*`, `message-reader` (including delayed host refocus) | -| Message reader motions, exact copy, read-only mappings/paste, streaming snapshot, paging and remembered positions | `integration/readers/text.test.ts` | `message-reader`, `reader-layout`, `session-copy` | -| Shell sections, panel mappings, whitespace, Unicode and delayed syntax/diff highlights | `integration/readers/shell.test.ts` | `shell-reader*`, `background-shell*`, `session-keymaps` | -| File reader ranges, gutters, partial/empty/non-file results, highlighting and disposal | `integration/readers/read.test.ts`, `unit/read-reader.test.ts` | `read-reader*` (including native modal resize) | -| Change views, per-view cursors, signs/colors, exact copy, mouse tabs, added/deleted/unknown patches | `integration/readers/diff.test.ts`, `unit/diff-reader.test.ts` | `diff-reader*`, `file-changes*` (including native modal resize) | -| Selection marker and yank colors, Unicode, viewport clipping, unchanged geometry | | `session-copy` (flash expiry and cancellation), `transcript-layout-*`, `transcript-partial-*` | -| Transcript source matching, anonymous/grouped/permission-blocked tool rows and patch files | | `background-shell*`, `permission-tools*`, `file-changes*`, `patch-running`, `transcript-grouped-*`, `transcript-low-detail-*`, `transcript-ungrouped-*`, `transcript-running-*`, `transcript-parts-*` | -| Exact whole-message/part clipboard payloads, including offscreen text, Markdown and Unicode | | `session-copy`, `transcript-layout-*`, `transcript-partial-*`, `transcript-parts-*` (captured OSC52 writes) | -| Messages arriving while browsing, streaming reader snapshot/selection and refreshed transcript | `integration/readers/text.test.ts` | `transcript-live-*` (real server with a controlled local model stream) | -| Visible and offscreen navigation, separate parts, latest/reasoning selection, natural bottom, virtualization and history compensation | | `transcript-layout-*`, `transcript-partial-*`, `transcript-parts-*`, `transcript-history-*` (animations on/off) | -| Source and published-package shared runtime | | `runtime-source`, `runtime-npm` (published `./tui` export, private Solid copies, reactive theme/status, manager and active-reader config unload/reload) | +| Behavior | Local coverage | Real OpenCode coverage | +| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Prompt focus, mode/status/theme, mappings, native key passthrough, cleanup | | `prompt-input`, `runtime-*`, `tab-switching`, `agent-switching*` | +| Clipboard Unicode/CRLF/counts, undo/redo, registers, async ordering, fallback, adapter cancellation | `integration/clipboard.test.ts` | `prompt-clipboard`, `session-copy`, `clipboard-cancel-*` (late response and queued keys across focus/route/toggle/unload) | +| Dialog query changes and command selection | | `dialog-focus`, `dialog-focus-normal`, `dialog-mappings`, `dialog-multikey-mappings`, `dialog-scope`, `prompt-dialog` (mode inheritance, filtered results after edit/undo/redo, insert/normal mappings, undo isolation, mapping-prefix priority, native navigation, pending motions and unrelated extension input) | +| Question form navigation, custom answers, insert mappings, submission/cancellation and prompt restoration | | `question-forms`, `question-forms-kj`, `question-forms-zz` | +| Subagent/shell/terminal panel Vim navigation, native shortcuts, disabled-plugin passthrough and prompt restoration | | `composer-navigation` | +| Real PTY passthrough, focus isolation, route/removal/disable/unload and nested Neovim | | `terminal-focus`, `terminal-application` | +| Ctrl+/ terminal creation/focus/hide, Alt+h/l pane focus, modern/legacy encodings, prompt modes/cursor styles and child input isolation | | `terminal-toggle`, `terminal-application` | +| Shared pane command mappings, default overrides, disabled/legacy chords, dynamic footer and released child keys | `unit/pane-keymaps.test.ts` | `terminal-mappings`, `terminal-bindings-disabled`, `terminal-binding-override` | +| Native prompt history, exit on movement, Unicode edits and mapped-command cursor preservation | | `prompt-history`, `prompt-command-cursor` | +| Session entry/exit, toggle key scope, mappings, control chords, narrow status layout | `unit/session-keymaps.test.ts` | `session-key-*`, `session-empty`, `session-keymaps`, `session-agent-binding` | +| Session and reader lifecycle, route changes, replacement dialogs and disable | `integration/readers/text.test.ts` | `session-lifecycle`, `runtime-*`, `message-reader` (including delayed host refocus) | +| Message reader motions, exact copy, read-only mappings/paste, streaming snapshot, paging and remembered positions | `integration/readers/text.test.ts` | `message-reader`, `reader-layout`, `session-copy` | +| Shell sections, panel mappings, whitespace, Unicode and delayed syntax/diff highlights | `integration/readers/shell.test.ts` | `shell-reader*`, `background-shell*`, `session-keymaps` | +| File reader ranges, gutters, partial/empty/non-file results, highlighting and disposal | `integration/readers/read.test.ts`, `unit/read-reader.test.ts` | `read-reader*` (including native modal resize) | +| Change views, per-view cursors, signs/colors, exact copy, mouse tabs, added/deleted/unknown patches | `integration/readers/diff.test.ts`, `unit/diff-reader.test.ts` | `diff-reader*`, `file-changes*` (including native modal resize) | +| Selection marker and yank colors, Unicode, viewport clipping, unchanged geometry | | `session-copy` (flash expiry and cancellation), `transcript-layout-*`, `transcript-partial-*` | +| Transcript source matching, anonymous/grouped/permission-blocked tool rows and patch files | | `background-shell*`, `permission-tools*`, `file-changes*`, `patch-running`, `transcript-grouped-*`, `transcript-low-detail-*`, `transcript-ungrouped-*`, `transcript-running-*`, `transcript-parts-*` | +| Exact whole-message/part clipboard payloads, including offscreen text, Markdown and Unicode | | `session-copy`, `transcript-layout-*`, `transcript-partial-*`, `transcript-parts-*` (captured OSC52 writes) | +| Messages arriving while browsing, streaming reader snapshot/selection and refreshed transcript | `integration/readers/text.test.ts` | `transcript-live-*` (real server with a controlled local model stream) | +| Visible and offscreen navigation, separate parts, latest/reasoning selection, natural bottom, virtualization and history compensation | | `transcript-layout-*`, `transcript-partial-*`, `transcript-parts-*`, `transcript-history-*` (animations on/off) | +| Source and published-package shared runtime | | `runtime-source`, `runtime-npm` (published `./tui` export, private Solid copies, reactive theme/status, manager and active-reader config unload/reload) | Most transcripts are imported saved messages. Live-response and prompt-history scenarios use a controlled local OpenAI-compatible stream; OpenCode owns message @@ -74,3 +74,27 @@ Other scenarios use the packed published export (or the unmodified source in Run `bun run typecheck`, `bun run test`, and `bun run test:e2e`. To work on one host regression, pass scenario names, for example: `bun run test:e2e message-reader session-lifecycle`. + +## E2E performance + +E2E runs up to four scenarios at a time, limited by available processors, after +building and packing the plugin once. CI uses two workers to limit contention. +Every scenario still gets its own server, workspace, configuration, and tmux +session. Workers take the next scenario as soon as they finish, and a failure +does not prevent the remaining scenarios from running. + +Use `E2E_WORKERS=1 bun run test:e2e` for sequential debugging, or set another +positive worker count to benchmark your machine. More workers can overload +small CI runners, so measure before increasing it. + +Each run saves `test-results/e2e/run-*/result.json` with total wall time, shared +setup time, execution wall time, and per-scenario timings for preparation, +server/session startup, TUI startup, assertions, and cleanup. Per-scenario times +overlap when running in parallel; their sum is not the run's wall time. CI uploads +these results even on success, and caches npm downloads and host/reference +binaries. OpenCode's latest stable version is still resolved on every run. + +Detached tmux is configured with explicit foreground/background colors so it +answers terminal color queries instead of delaying host startup until theme +detection times out. Screen assertions poll every 50 ms, with unchanged timeouts. +Per-fixture update checks are disabled; the runner resolves the host version once. diff --git a/test/e2e/run.ts b/test/e2e/run.ts index 42c937f..535b682 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -1,7 +1,8 @@ import { execFileSync } from "node:child_process" import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { availableParallelism } from "node:os" import path from "node:path" -import { runWithFixture, type Fixture, type FixtureSetup } from "./support/fixture" +import { runWithFixture, type Fixture, type FixtureSetup, type FixtureTimings } from "./support/fixture" import { installOpenCode } from "./support/opencode" import { copySourcePlugin, packPlugin } from "./support/plugin" import { agentSwitching } from "./scenarios/agent-switching" @@ -342,6 +343,9 @@ const requested = Bun.argv.slice(2) for (const name of requested) { if (!scenarios.some((scenario) => scenario.name === name)) throw new Error(`Unknown E2E scenario: ${name}`) } +const workers = Number(process.env.E2E_WORKERS ?? Math.min(4, availableParallelism())) +if (!Number.isSafeInteger(workers) || workers < 1) throw new Error("E2E_WORKERS must be a positive integer") +const selected = scenarios.filter((scenario) => !requested.length || requested.includes(scenario.name)) 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, "../..") @@ -350,40 +354,69 @@ 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 }> = [] +const results: Array<{ + name: string + status: "passed" | "failed" + milliseconds: number + timings: FixtureTimings + error?: string +}> = [] let version: string | undefined let setupFailure: string | undefined +const started = Date.now() +let setupMilliseconds = 0 +let executionMilliseconds = 0 console.log(`E2E artifacts: ${artifacts}`) +console.log(`E2E workers: ${Math.min(workers, selected.length)}`) try { const opencode = await installOpenCode() version = opencode.version const plugin = await packPlugin(root, temporary, artifacts) const sourcePlugin = await copySourcePlugin(root, temporary) const clipboardPlugin = await copySourcePlugin(root, temporary, true) + setupMilliseconds = Date.now() - started - for (const scenario of scenarios) { - if (requested.length && !requested.includes(scenario.name)) continue - const started = Date.now() - try { - await runWithFixture( - { - ...scenario.setup, - opencode, - plugin: scenario.setup?.delayedClipboard ? clipboardPlugin : scenario.source ? sourcePlugin : 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 + let next = 0 + async function worker() { + while (next < selected.length) { + // Claim a scenario before awaiting; each worker owns a separate fixture. + const index = next++ + const scenario = selected[index]! + const started = Date.now() + const timings: FixtureTimings = {} + try { + await runWithFixture( + { + ...scenario.setup, + opencode, + plugin: scenario.setup?.delayedClipboard ? clipboardPlugin : scenario.source ? sourcePlugin : plugin, + directory: path.join(temporary, scenario.name), + artifacts: path.join(artifacts, scenario.name), + timings, + }, + scenario.run, + ) + results[index] = { name: scenario.name, status: "passed", milliseconds: Date.now() - started, timings } + console.log(`PASS: ${scenario.name}`) + } catch (error) { + results[index] = { + name: scenario.name, + status: "failed", + milliseconds: Date.now() - started, + timings, + error: String(error), + } + console.error(`FAIL: ${scenario.name}`, error) + process.exitCode = 1 + } } } + const running: Promise[] = [] + const executionStarted = Date.now() + for (let i = 0; i < Math.min(workers, selected.length); i++) running.push(worker()) + await Promise.all(running) + executionMilliseconds = Date.now() - executionStarted } catch (error) { setupFailure = String(error) console.error("FAIL: setup", error) @@ -396,6 +429,10 @@ try { opencode: version, bun: Bun.version, tmux: execFileSync("tmux", ["-V"], { encoding: "utf8" }).trim(), + workers: Math.min(workers, selected.length), + milliseconds: Date.now() - started, + setupMilliseconds, + executionMilliseconds, scenarios: results, setupFailure, }, @@ -404,4 +441,5 @@ try { ), ) await rm(temporary, { recursive: true, force: true }) + console.log(`E2E completed in ${((Date.now() - started) / 1000).toFixed(1)}s`) } diff --git a/test/e2e/support/colors.ts b/test/e2e/support/colors.ts index 5930ad4..11ebbde 100644 --- a/test/e2e/support/colors.ts +++ b/test/e2e/support/colors.ts @@ -84,7 +84,17 @@ export function yankFlashMatches(before: string, after: string, colors: { foregr .includes("SESSION") ) continue - if (JSON.stringify(trim(row)) !== JSON.stringify(trim(actual))) return false + const expected = trim(row) + const observed = trim(actual) + if (expected.length !== observed.length) return false + // The composer's border/agent foreground also animates independently. + const composer = row.some((cell) => cell.text === "┃" || cell.text === "╹") + for (let x = 0; x < expected.length; x++) { + const before = expected[x] + const after = observed[x] + if (before.text !== after.text || before.background !== after.background) return false + if (!composer && before.foreground !== after.foreground) return false + } } } return painted diff --git a/test/e2e/support/fixture.ts b/test/e2e/support/fixture.ts index ee967d0..730570b 100644 --- a/test/e2e/support/fixture.ts +++ b/test/e2e/support/fixture.ts @@ -37,9 +37,20 @@ type Options = FixtureSetup & { plugin: string directory: string artifacts: string + timings: FixtureTimings } +export type FixtureTimings = Partial> + export async function runWithFixture(options: Options, run: (fixture: Fixture) => Promise) { + let phase: keyof FixtureTimings = "preparation" + let phaseStarted = Date.now() + function mark(next: keyof FixtureTimings) { + options.timings[phase] = Date.now() - phaseStarted + phase = next + phaseStarted = Date.now() + } + const { opencode, plugin, directory, artifacts } = options const workspace = path.join(directory, "workspace") const password = crypto.randomUUID() @@ -67,11 +78,12 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = function interrupt() { stop() - process.exit(130) + // Let the other parallel fixtures stop before exiting. + setImmediate(() => process.exit(130)) } function terminate() { stop() - process.exit(143) + setImmediate(() => process.exit(143)) } process.once("SIGINT", interrupt) process.once("SIGTERM", terminate) @@ -98,6 +110,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = }), ) + mark("server") const listener = createServer() await new Promise((resolve, reject) => { listener.once("error", reject) @@ -140,6 +153,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = const imported = (await request(`/api/experimental/session/${session.id}/export`)).data assert.equal(imported.messages.length, messages.length) } + mark("terminal") 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) @@ -147,6 +161,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = const initialMode = options.vim?.defaultMode === "normal" ? "NORMAL" : "INSERT" await terminal.screen("startup", (text) => text.includes(sessionTitle) && text.includes(initialMode), 60_000) + mark("scenario") await run({ terminal, sessionTitle, @@ -160,6 +175,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = }) assert.equal((await request(`/api/session/${session.id}`)).data.id, session.id) } finally { + mark("cleanup") process.off("SIGINT", interrupt) process.off("SIGTERM", terminate) stop() @@ -170,6 +186,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = if (error.code !== "ENOENT") throw error }) } + options.timings.cleanup = Date.now() - phaseStarted } function environment(home: string): Record { @@ -187,6 +204,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_DISABLE_FILEWATCHER: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_AUTOUPDATE: "true", OPENCODE_PASSWORD: password, XDG_CONFIG_HOME: path.join(directory, "xdg-config"), XDG_DATA_HOME: path.join(directory, "data"), diff --git a/test/e2e/support/terminal.ts b/test/e2e/support/terminal.ts index 99ce6b8..4198c4e 100644 --- a/test/e2e/support/terminal.ts +++ b/test/e2e/support/terminal.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from "node:child_process" import assert from "node:assert/strict" -import { readFileSync } from "node:fs" +import { readFileSync, writeFileSync } from "node:fs" import { appendFile } from "node:fs/promises" import path from "node:path" import { stripVTControlCharacters } from "node:util" @@ -16,11 +16,15 @@ export function createTerminal(socket: string, artifacts: string) { } function start(command: string[], workspace: string) { + const config = path.join(path.dirname(socket), "tmux.conf") + // Detached tmux has no client colors. Explicit colors let it answer OSC + // queries, rather than making OpenCode wait for theme detection to time out. + writeFileSync(config, 'set -g window-style "fg=#ffffff,bg=#000000"\n') pid = Number( tmux( "-u", "-f", - "/dev/null", + config, "new-session", "-d", "-s", @@ -125,7 +129,7 @@ export function createTerminal(socket: string, artifacts: string) { await Bun.write(path.join(artifacts, `${label}.ansi`), ansi) throw new Error(`OpenCode exited while waiting for ${label}`) } - await Bun.sleep(100) + await Bun.sleep(50) } } diff --git a/test/unit/yank-flash.test.ts b/test/unit/yank-flash.test.ts new file mode 100644 index 0000000..8163a69 --- /dev/null +++ b/test/unit/yank-flash.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { yankFlashMatches } from "../e2e/support/colors" + +const normal = "\x1b[38;2;238;238;238m\x1b[48;2;10;10;10m" +const flash = "\x1b[38;2;10;10;10m\x1b[48;2;86;182;194m" +const composer = "\x1b[38;2;86;130;191m\x1b[48;2;30;30;30m┃ Build" +const colors = { foreground: "#0a0a0a", background: "#56b6c2" } +const before = ["Tab", `${normal}▎ Selected`, `${normal}Other message`, composer, `${normal}SESSION`].join("\n") +const after = ["Tab", `${flash}▎ Selected`, `${normal}Other message`, composer, `${normal}Copied`].join("\n") + +describe("yank flash screen matching", () => { + test("matches selected text with otherwise unchanged colors", () => { + expect(yankFlashMatches(before, after, colors)).toBe(true) + }) + + test("allows the composer's independent foreground animation", () => { + expect(yankFlashMatches(before, after.replace("86;130;191", "88;141;214"), colors)).toBe(true) + }) + + test("rejects changed composer text or background", () => { + expect(yankFlashMatches(before, after.replace("Build", "Plan"), colors)).toBe(false) + expect(yankFlashMatches(before, after.replace("30;30;30", "40;40;40"), colors)).toBe(false) + }) + + test("rejects flash colors outside the selection", () => { + expect(yankFlashMatches(before, after.replace(composer, `${flash}┃ Build`), colors)).toBe(false) + }) + + test("rejects changed unselected transcript colors", () => { + expect(yankFlashMatches(before, after.replace(`${normal}Other`, `${flash}Other`), colors)).toBe(false) + }) + + test("rejects changed selected text or incorrect flash colors", () => { + expect(yankFlashMatches(before, after.replace("Selected", "Changed"), colors)).toBe(false) + expect(yankFlashMatches(before, after.replace("86;182;194", "80;180;190"), colors)).toBe(false) + }) +}) From 93013b54faac69bb0cf048f7ebee6168a600f7d0 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 3 Oct 2026 14:04:37 -0400 Subject: [PATCH 2/3] Use native editor history for Vim undo and redo --- src/plugin.tsx | 5 +- src/vim/edit.ts | 5 +- src/vim/editor.ts | 6 +- src/vim/history.ts | 138 +++++++++++++++++++++ src/vim/vimee.ts | 92 +++++++++++--- test/README.md | 18 +++ test/e2e/run.ts | 2 + test/e2e/scenarios/native-history.ts | 45 +++++++ test/helpers/fixture.ts | 6 +- test/integration/history.test.ts | 172 +++++++++++++++++++++++++++ test/integration/reference.test.ts | 10 ++ test/unit/edit.test.ts | 15 +-- 12 files changed, 479 insertions(+), 35 deletions(-) create mode 100644 src/vim/history.ts create mode 100644 test/e2e/scenarios/native-history.ts create mode 100644 test/integration/history.test.ts diff --git a/src/plugin.tsx b/src/plugin.tsx index 0ae4651..ca22fcf 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { Plugin } from "@opencode/plugin/tui" import type { Context } from "@opencode/plugin/tui/context" -import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" +import { KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" import { createEffect, createSignal, onCleanup, untrack } from "solid-js" import type { EditorContext } from "./vim/editor" import { createVimConfig } from "./vim/config" @@ -296,8 +296,7 @@ function createEditorContext(context: Context): EditorContext { }, setText(text) { const input = context.renderer.currentFocusedEditor - if (input instanceof InputRenderable) input.value = text - else if (input) editInput(input, text, context.renderer.widthMethod) + if (input) editInput(input, text, context.renderer.widthMethod) }, submit() { if (inputKind(context) === "dialog") { diff --git a/src/vim/edit.ts b/src/vim/edit.ts index 6ec0933..a30f828 100644 --- a/src/vim/edit.ts +++ b/src/vim/edit.ts @@ -8,6 +8,7 @@ type Input = { cursorOffset: number setSelection: (start: number, end: number) => void insertText: (text: string) => void + deleteChar: () => unknown clearSelection: () => unknown } @@ -49,7 +50,9 @@ export function editInput(input: Input, value: string, widthMethod: WidthMethod) if (start === end) input.cursorOffset = startOffset else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start, widthMethod)) - input.insertText(value.slice(start, valueEnd)) + const inserted = value.slice(start, valueEnd) + if (inserted) input.insertText(inserted) + else input.deleteChar() } function segmentAt(text: string, segments: Intl.Segments, index: number) { diff --git a/src/vim/editor.ts b/src/vim/editor.ts index 0104ace..79a6851 100644 --- a/src/vim/editor.ts +++ b/src/vim/editor.ts @@ -1,4 +1,4 @@ -import type { LineInfo, RGBA, WidthMethod } from "@opentui/core" +import type { EditBuffer, LineInfo, RGBA, WidthMethod } from "@opentui/core" export type EditorContext = { input: () => EditorInput | undefined @@ -15,11 +15,15 @@ export type EditorInput = { isDestroyed?: boolean cursorOffset: number plainText: string + editBuffer?: EditBuffer + undo?: () => boolean + redo?: () => boolean visualCursor?: { visualCol: number } editorView?: { getLogicalLineInfo?: () => LineInfo setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void resetSelection?: () => void + deleteSelectedText?: () => void } selectionBg?: RGBA selectionFg?: RGBA diff --git a/src/vim/history.ts b/src/vim/history.ts new file mode 100644 index 0000000..5abac01 --- /dev/null +++ b/src/vim/history.ts @@ -0,0 +1,138 @@ +import type { EditorInput } from "./editor" + +type Point = { position: number; cursor: number } +type Change = { before: Point; after: Point } +type Method = (...args: any[]) => any + +export function createNativeHistory(input: EditorInput) { + const buffer = input.editBuffer + if (!buffer || !input.undo || !input.redo) return + + let position = 0 + let version = 0 + let pending: Point | undefined + const changes: Change[] = [] + const restore: Array<() => void> = [] + + function point(): Point { + return { position, cursor: input.isDestroyed ? 0 : input.cursorOffset } + } + + function begin() { + if (!pending || pending.position === position) pending = point() + } + + function end() { + if (pending && position > pending.position) changes.push({ before: pending, after: point() }) + pending = undefined + } + + function observe(target: object, name: string, kind: "edit" | "reset" | "undo" | "redo") { + const methods = target as Record + const original = methods[name] + const wrapped: Method = function (...args) { + if (kind === "undo" || kind === "redo") { + end() + const result = original.apply(target, args) + if (result !== null) position += kind === "undo" ? -1 : 1 + return result + } + if (kind === "reset") { + const result = original.apply(target, args) + position = 0 + version++ + pending = undefined + changes.length = 0 + return result + } + + const before = input.plainText + const ownGroup = !pending + begin() + const result = original.apply(target, args) + // replaceText creates an undo entry even when its contents are unchanged. + if (input.plainText !== before || name === "replaceText" || name === "replaceTextOwned") { + for (let index = 0; index < changes.length; index++) { + const change = changes[index]! + if (change.after.position <= position) continue + if (change.before.position < position) { + change.after = { position, cursor: pending!.cursor } + changes.length = index + 1 + } else changes.length = index + break + } + position++ + } + if (ownGroup) end() + return result + } + methods[name] = wrapped + restore.push(() => { + if (methods[name] === wrapped) methods[name] = original + }) + } + + for (const name of [ + "insertChar", + "insertText", + "deleteChar", + "deleteCharBackward", + "deleteRange", + "deleteLine", + "newLine", + "replaceText", + "replaceTextOwned", + ]) + observe(buffer, name, "edit") + for (const name of ["setText", "setTextOwned", "clear", "clearHistory"]) observe(buffer, name, "reset") + observe(buffer, "undo", "undo") + observe(buffer, "redo", "redo") + if (input.editorView?.deleteSelectedText) observe(input.editorView, "deleteSelectedText", "edit") + + function move(direction: "undo" | "redo") { + end() + let change: Change | undefined + if (direction === "undo") { + for (let index = changes.length - 1; index >= 0; index--) { + if (changes[index]!.before.position < position) { + if (position <= changes[index]!.after.position) change = changes[index] + break + } + } + } else { + for (const candidate of changes) { + if (candidate.after.position > position) { + if (position >= candidate.before.position) change = candidate + break + } + } + } + const target = change ? (direction === "undo" ? change.before : change.after) : undefined + const before = position + do { + if (direction === "undo") { + if (!buffer!.canUndo()) break + input.undo!() + } else { + if (!buffer!.canRedo()) break + input.redo!() + } + } while (target && position !== target.position) + if (position !== before && target) input.cursorOffset = target.cursor + return position !== before + } + + return { + begin, + end, + version: () => version, + undo: () => move("undo"), + redo: () => move("redo"), + cleanup() { + end() + for (const stop of restore) stop() + }, + } +} + +export type NativeHistory = NonNullable> diff --git a/src/vim/vimee.ts b/src/vim/vimee.ts index f0b1834..c65e372 100644 --- a/src/vim/vimee.ts +++ b/src/vim/vimee.ts @@ -11,6 +11,7 @@ import type { CursorPosition, KeybindDefinition, MotionRange, VimAction as Vimee import type { EditorInput, EditorContext } from "./editor" import type { VimConfig } from "./config" import type { VimLog } from "./log" +import { createNativeHistory, type NativeHistory } from "./history" import { createGraphemeCodec } from "./graphemes" import { createKeybinds, hasNormalKeyPrefix, insertHostAction, type HostKeybindDefinition } from "./keymaps" import { keyForVimee, keyToken, tokenCtrl } from "./keys" @@ -39,6 +40,11 @@ type VimState = ReturnType type HostAction = (VimeeAction & { register?: string }) | { type: "submit" } | { type: "command"; command: string } type HostRange = { start: number; end: number } +// Vimee handles motions and operators; the native editor owns undo history. +class MotionBuffer extends TextBuffer { + override saveUndoPoint() {} +} + export const YANK_FLASH_MS = 250 export type VimeeAdapter = ReturnType @@ -51,7 +57,7 @@ type AdapterOptions = { export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog, options: AdapterOptions = {}) { const codec = createGraphemeCodec() - let buffer = new TextBuffer("") + let buffer = new MotionBuffer("") let activeMap = createPromptMap("", codec) let vim = createInitialContext({ line: 0, col: 0 }) const keybinds = options.readOnly ? undefined : createKeybinds(config, log) @@ -61,7 +67,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL let pendingInsert = "" let pendingTarget: { input: EditorInput; offset: number } | undefined let pendingContext: EditorContext | undefined - let nativeInsertUndoSaved = false + let nativeInsertChanged = false + const histories = new Map() + let history: NativeHistory | undefined + let historyVersion = 0 let historyText: string | undefined let activeInput: EditorInput | undefined let widthMethod: WidthMethod = "unicode" @@ -84,6 +93,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL const input = ctx.input() if (!input) return false attach(ctx) + history?.begin() let vimeeKey = keyForVimee(event, key) if (!vimeeKey) return false @@ -95,6 +105,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL vimeeKey = codec.encode(vimeeKey) if (state.mode() === "insert") { + const map = mapForHostText(input.plainText) + sync(hostPosition(map, input.cursorOffset)) if (vimeeKey === "Escape") { cancelPendingInsert(ctx) enterNormal(ctx) @@ -196,7 +208,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } vim = result.newCtx const actions = result.actions as HostAction[] - if (resolved?.status !== "matched") applyActions(actions, ctx, map) + if (resolved?.status !== "matched") applyActions(actions, ctx, activeMap) if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange) syncMode(state, vim.mode) @@ -217,7 +229,13 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL return consumesKey(commandKey, actions, vim, keybindPending) } }, - cleanup: suspend, + cleanup() { + suspend() + for (const item of histories.values()) item.cleanup() + histories.clear() + history = undefined + activeInput = undefined + }, } function needsClipboard(key: string, ctrl: boolean, definition?: KeybindDefinition) { @@ -239,8 +257,20 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL activeInput = input widthMethod = ctx.widthMethod activeMap = createPromptMap(input?.plainText ?? "", codec, widthMethod) - buffer = new TextBuffer(activeMap.vimText) - nativeInsertUndoSaved = false + buffer = new MotionBuffer(activeMap.vimText) + for (const [editor, item] of histories) { + if (!editor.isDestroyed) continue + item.cleanup() + histories.delete(editor) + } + history = input && !options.readOnly ? histories.get(input) : undefined + if (input && !options.readOnly && !history) { + history = createNativeHistory(input) + if (history) histories.set(input, history) + } + historyVersion = history?.version() ?? 0 + if (state.mode() === "insert") history?.begin() + nativeInsertChanged = false vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() } } @@ -248,6 +278,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL generation++ preferredColumn = undefined if (pendingContext) cancelPendingInsert(pendingContext) + history?.end() if (timer) clearTimeout(timer) timer = undefined keybinds?.cancel() @@ -266,19 +297,23 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } function mapForHostText(text: string) { + if (history && historyVersion !== history.version()) { + historyVersion = history.version() + activeMap = createPromptMap(text, codec, widthMethod) + buffer = new MotionBuffer(activeMap.vimText) + vim = { ...resetContext(vim), lastChange: [], pendingChange: [] } + nativeInsertChanged = false + } if (activeMap.hostText === text) return activeMap const nextMap = createPromptMap(text, codec, widthMethod) if (state.mode() === "insert") recordNativeChange(nextMap) activeMap = nextMap if (state.mode() === "insert") { - if (!nativeInsertUndoSaved) { - buffer.saveUndoPoint(vim.cursor) - nativeInsertUndoSaved = true - } + nativeInsertChanged = true buffer.replaceContent(activeMap.vimText) } else { - buffer = new TextBuffer(activeMap.vimText) - nativeInsertUndoSaved = false + buffer = new MotionBuffer(activeMap.vimText) + nativeInsertChanged = false } return activeMap } @@ -309,6 +344,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL const input = ctx.input() let currentMap = map if (!input) return + history?.begin() for (const action of actions) { switch (action.type) { @@ -323,7 +359,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL if (!action.register) options.onYank?.(codec.decode(action.text)) break case "mode-change": - nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change") + nativeInsertChanged = action.mode === "insert" && actions.some((item) => item.type === "content-change") if (action.mode === "insert") { cancelYankFlash() clearVisualSelection(input) @@ -345,6 +381,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL // The engine context already has the final cursor; move the editor once. setCursor(input, currentMap, vim.cursor) syncVisualSelection(input, currentMap, ctx) + if (vim.mode === "insert") history?.begin() + else history?.end() } function handleInsertKeybind(event: KeyEvent, key: string, ctx: EditorContext): boolean { @@ -421,7 +459,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL clampNormalCursor(input, map) } - const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange + const lastChange = nativeInsertChanged ? [...vim.pendingChange, "Escape"] : vim.lastChange vim = { ...resetContext(vim), cursor: hostPosition(map, input?.cursorOffset ?? 0), @@ -430,8 +468,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL lastChange, pendingChange: [], } - nativeInsertUndoSaved = false + nativeInsertChanged = false syncMode(state, "normal") + history?.end() } function applyKeybind(definition: KeybindDefinition, ctx: EditorContext, map: PromptMap) { @@ -475,6 +514,23 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } function processKeyInner(key: string, ctrl: boolean): { newCtx: VimContext; actions: HostAction[] } { + if (vim.mode === "normal" && vim.phase === "idle" && ((!ctrl && key === "u") || (ctrl && key === "r"))) { + const redo = ctrl + let changed = false + for (let count = vim.count || 1; count > 0; count--) { + if (!(redo ? history?.redo() : history?.undo())) break + changed = true + } + activeMap = createPromptMap(activeInput?.plainText ?? "", codec, widthMethod) + buffer = new MotionBuffer(activeMap.vimText) + if (activeInput) clampNormalCursor(activeInput, activeMap) + const cursor = hostPosition(activeMap, activeInput?.cursorOffset ?? 0) + const message = changed ? "" : redo ? "Already at newest change" : "Already at oldest change" + return { + newCtx: { ...resetContext(vim), cursor, pendingChange: [], statusMessage: message }, + actions: [{ type: "cursor-move", position: cursor }], + } + } let range = ctrl ? undefined : lineMotion(key) if ( range && @@ -511,7 +567,6 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL // A repeated insert/change is one undo step, including native typing. const original = buffer const before = buffer.getContent() - const cursor = vim.cursor const keys = [...vim.lastChange] if (vim.count) { while (/^[0-9]$/.test(keys[0] ?? "")) keys.shift() @@ -519,15 +574,14 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } vim = { ...vim, count: 0 } const actions: HostAction[] = [] - buffer = new TextBuffer(before) + buffer = new MotionBuffer(before) try { for (const token of keys) { - const result = processKey(token) + const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token)) vim = result.newCtx actions.push(...result.actions) } if (buffer.getContent() !== before) { - original.saveUndoPoint(cursor) original.replaceContent(buffer.getContent()) } } finally { diff --git a/test/README.md b/test/README.md index 69a7a5e..daf16e2 100644 --- a/test/README.md +++ b/test/README.md @@ -41,6 +41,7 @@ configuration fallback stays in unit tests. | Ctrl+/ terminal creation/focus/hide, Alt+h/l pane focus, modern/legacy encodings, prompt modes/cursor styles and child input isolation | | `terminal-toggle`, `terminal-application` | | Shared pane command mappings, default overrides, disabled/legacy chords, dynamic footer and released child keys | `unit/pane-keymaps.test.ts` | `terminal-mappings`, `terminal-bindings-disabled`, `terminal-binding-override` | | Native prompt history, exit on movement, Unicode edits and mapped-command cursor preservation | | `prompt-history`, `prompt-command-cursor` | +| Native undo/redo, Vim change grouping, mixed native/Vim history, reset/cleanup, rich-item submission | `integration/history.test.ts`, Neovim parity | `native-history` (original pasted contents and image/file payloads after insertion undo/redo), `dialog-focus`, `dialog-mappings` | | Session entry/exit, toggle key scope, mappings, control chords, narrow status layout | `unit/session-keymaps.test.ts` | `session-key-*`, `session-empty`, `session-keymaps`, `session-agent-binding` | | Session and reader lifecycle, route changes, replacement dialogs and disable | `integration/readers/text.test.ts` | `session-lifecycle`, `runtime-*`, `message-reader` (including delayed host refocus) | | Message reader motions, exact copy, read-only mappings/paste, streaming snapshot, paging and remembered positions | `integration/readers/text.test.ts` | `message-reader`, `reader-layout`, `session-copy` | @@ -75,6 +76,23 @@ Run `bun run typecheck`, `bun run test`, and `bun run test:e2e`. To work on one host regression, pass scenario names, for example: `bun run test:e2e message-reader session-lifecycle`. +## Native history contract + +OpenTUI restores editor content; the plugin groups native entries into Vim changes +and records their cursor positions. `src/vim/history.ts` is a compatibility layer +because the editor has no public undo-group API. It wraps public methods, not +private attachment state. + +The tracker assumes each changed edit adds one native history entry; `replaceText` +also adds an entry for unchanged text. Native undo/redo move one entry, while +`setText`, `clear`, and `clearHistory` reset history. Entries predating attachment +retain native granularity because their Vim boundaries are unknown. + +When updating OpenTUI, run the real-component history tests, Neovim parity tests +(including exact cursor positions), and the real-host `native-history` scenario. +The host's existing rich-item deletion/undo and highlighting bugs remain separate +from grouping; registers are still text-only. + ## E2E performance E2E runs up to four scenarios at a time, limited by available processors, after diff --git a/test/e2e/run.ts b/test/e2e/run.ts index 535b682..89296e7 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -32,6 +32,7 @@ import { layoutMessages, partialMessages, partMessages, copyMessages, longReader import { transcriptLive } from "./scenarios/transcript-live" import { pluginLifecycle } from "./scenarios/plugin-lifecycle" import { promptClipboard, promptCommandCursor, promptHistory, promptInput } from "./scenarios/prompt-input" +import { nativeHistory } from "./scenarios/native-history" import { permissionMessages } from "./data/permission" import { permissionTools } from "./scenarios/permission-tools" import { clipboardCancellation } from "./scenarios/clipboard-cancel" @@ -59,6 +60,7 @@ const scenarios: Array<{ }, { name: "prompt-clipboard", run: promptClipboard, setup: { vim: { keymaps: { normal: { Q: "yiw$p" } } } } }, { name: "prompt-history", run: promptHistory, setup: { stream: "history response" } }, + { name: "native-history", run: nativeHistory, setup: { probe: true, stream: "native history response" } }, { name: "prompt-command-cursor", run: promptCommandCursor, diff --git a/test/e2e/scenarios/native-history.ts b/test/e2e/scenarios/native-history.ts new file mode 100644 index 0000000..58022cd --- /dev/null +++ b/test/e2e/scenarios/native-history.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" + +export async function nativeHistory({ terminal, probe, request, sessionID, stream, workspace }: Fixture) { + assert(stream) + const pasted = "Native undo first line\nsecond original line\nthird original line\nfourth original line" + const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" + await Bun.write(`${workspace}/native-history.txt`, "Native history file contents\n") + await Bun.write(`${workspace}/native-history.png`, Buffer.from(image, "base64")) + + for (const [index, kind] of ["paste", "image", "file"].entries()) { + if (index) await terminal.type("i") + await terminal.screen(`${kind}-insert`, (text) => text.includes("INSERT")) + if (kind === "file") { + await terminal.type("@native-history.txt") + await terminal.screen("file-autocomplete", (text) => text.includes("native-history.txt")) + await terminal.keys("Enter") + } else await terminal.paste(kind === "paste" ? pasted : `${workspace}/native-history.png`) + const label = kind === "paste" ? "[Pasted ~4 lines]" : kind === "image" ? "[Image 1]" : "@native-history.txt" + await terminal.screen(`${kind}-attached`, async () => (await probe()).editor?.text.includes(label)) + const original = (await probe()).editor.text + await terminal.keys("Escape") + await terminal.screen(`${kind}-normal`, (text) => text.includes("NORMAL")) + await terminal.type("u") + await terminal.screen(`${kind}-undone`, async () => (await probe()).editor?.text === "") + await terminal.keys("C-r") + await terminal.screen(`${kind}-redone`, async () => (await probe()).editor?.text === original) + await terminal.keys("Enter") + await terminal.screen(`${kind}-submitted`, (text) => text.split("native history response").length === index + 2) + stream.write("", true) + await terminal.screen(`${kind}-completed`, async () => { + const exported = await request(`/api/experimental/session/${sessionID}/export`) + const assistant = exported.data.messages.filter((message: any) => message.type === "assistant").at(-1) + return !!assistant?.time.completed + }) + const exported = await request(`/api/experimental/session/${sessionID}/export`) + const user = exported.data.messages.filter((message: any) => message.type === "user").at(-1) + if (kind === "paste") assert.equal(user.text.trim(), pasted, "Redo must restore the original pasted contents") + else { + assert.equal(user.files.length, 1, "Redo must restore the attachment, not just its label") + if (kind === "image") assert.equal(user.files[0].data, image) + else assert.equal(user.files[0].name, "native-history.txt") + } + } +} diff --git a/test/helpers/fixture.ts b/test/helpers/fixture.ts index 0960e91..17745ee 100644 --- a/test/helpers/fixture.ts +++ b/test/helpers/fixture.ts @@ -1,4 +1,4 @@ -import { RGBA, TextareaRenderable, type KeyEvent } from "@opentui/core" +import { InputRenderable, RGBA, TextareaRenderable, type KeyEvent } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import type { EditorContext } from "../../src/vim/editor" import { createVimConfig, type VimOptions } from "../../src/vim/config" @@ -12,15 +12,17 @@ export async function createFixture( options: VimOptions = {}, width = 80, adapterOptions: Parameters[3] = {}, + Input: typeof TextareaRenderable = TextareaRenderable, ) { const screen = await createTestRenderer({ width, height: 12, kittyKeyboard: true }) - const input = new TextareaRenderable(screen.renderer, { + const input = new Input(screen.renderer, { id: "prompt", width, height: 10, initialValue: text, wrapMode: "word", }) + if (input instanceof InputRenderable) input.value = text screen.renderer.root.add(input) input.focus() await screen.renderOnce() diff --git a/test/integration/history.test.ts b/test/integration/history.test.ts new file mode 100644 index 0000000..019a794 --- /dev/null +++ b/test/integration/history.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { InputRenderable } from "@opentui/core" +import { createFixture } from "../helpers/fixture" + +let fixture: Awaited> | undefined +afterEach(() => { + fixture?.dispose() + fixture = undefined +}) + +describe("native Vim history", () => { + test("undo and redo restore through the native editor", async () => { + fixture = await createFixture("one two") + const undo = spyOn(fixture.input, "undo") + const redo = spyOn(fixture.input, "redo") + await fixture.keys("ciwnew") + fixture.mockInput.pressEscape() + await fixture.keys("u") + expect(fixture.input.plainText).toBe("one two") + expect(undo).toHaveBeenCalled() + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("new two") + expect(redo).toHaveBeenCalled() + undo.mockRestore() + redo.mockRestore() + }) + + test("a repeated intermediate text does not split an insertion's history", async () => { + fixture = await createFixture("abc") + await fixture.keys("AX") + fixture.mockInput.pressBackspace() + await fixture.keys("Y") + fixture.mockInput.pressEscape() + await fixture.keys("u") + expect(fixture.input.plainText).toBe("abc") + expect(fixture.input.editBuffer.canUndo()).toBe(false) + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abcY") + expect(fixture.input.editBuffer.canRedo()).toBe(false) + }) + + test("an insertion with no net text change still has a complete undo group", async () => { + fixture = await createFixture("abc") + await fixture.keys("AX") + fixture.mockInput.pressBackspace() + fixture.mockInput.pressEscape() + await fixture.keys("u") + expect(fixture.input.plainText).toBe("abc") + expect(fixture.input.editBuffer.canUndo()).toBe(false) + }) + + test("counts undo and redo whole Vim changes", async () => { + fixture = await createFixture("abcdef") + await fixture.keys("xx2u") + expect(fixture.input.plainText).toBe("abcdef") + await fixture.keys("2") + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("cdef") + }) + + test("native undo and Vim undo share the same history", async () => { + fixture = await createFixture("abc") + await fixture.keys("AXY") + fixture.mockInput.pressKey("-", { ctrl: true }) + fixture.mockInput.pressEscape() + await fixture.keys("u") + expect(fixture.input.plainText).toBe("abc") + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abcXY") + }) + + test("a new change replaces the native redo branch", async () => { + fixture = await createFixture("abc") + await fixture.keys("xuAX") + fixture.mockInput.pressEscape() + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abcX") + await fixture.keys("u") + expect(fixture.input.plainText).toBe("abc") + }) + + test("redo does not replace the dot-repeat command", async () => { + fixture = await createFixture("abcdef") + await fixture.keys("xu") + fixture.mockInput.pressKey("r", { ctrl: true }) + await fixture.keys(".") + fixture.mockInput.pressEnter() + expect(fixture.input.plainText).toBe("cdef") + expect(fixture.submissions).toBe(1) + }) + + test("host replacement starts a new history and repeat boundary", async () => { + fixture = await createFixture("old") + await fixture.keys("AX") + fixture.mockInput.pressEscape() + fixture.input.setText("new draft") + await fixture.keys("u.") + expect(fixture.input.plainText).toBe("new draft") + await fixture.keys("AZ") + fixture.mockInput.pressEscape() + await fixture.keys("u") + expect(fixture.input.plainText).toBe("new draft") + }) + + test("same-text native replacement still creates a history entry", async () => { + fixture = await createFixture("abc") + fixture.input.editBuffer.replaceText("abc") + await fixture.keys("xu") + expect(fixture.input.plainText).toBe("abc") + expect(fixture.input.editBuffer.canUndo()).toBe(true) + await fixture.keys("u") + expect(fixture.input.plainText).toBe("abc") + expect(fixture.input.editBuffer.canUndo()).toBe(false) + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abc") + expect(fixture.input.editBuffer.canRedo()).toBe(true) + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("bc") + expect(fixture.input.editBuffer.canRedo()).toBe(false) + }) + + test("clearing native history resets grouping and repeat without changing text", async () => { + fixture = await createFixture("abc") + await fixture.keys("AX") + fixture.mockInput.pressEscape() + fixture.input.editBuffer.clearHistory() + await fixture.keys("u.") + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abcX") + expect(fixture.input.editBuffer.canUndo()).toBe(false) + expect(fixture.input.editBuffer.canRedo()).toBe(false) + }) + + test("single-line inputs retain history and emit native input changes", async () => { + fixture = await createFixture("one two", {}, 80, {}, InputRenderable) + fixture.input.cursorOffset = 0 + const values: string[] = [] + fixture.input.on("input", (text: string) => values.push(text)) + await fixture.keys("ciwnew") + fixture.mockInput.pressEscape() + await fixture.keys("u") + expect(fixture.input.plainText).toBe("one two") + expect(values.at(-1)).toBe("one two") + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("new two") + expect(values.at(-1)).toBe("new two") + }) + + test("cleanup restores native methods without clearing native history", async () => { + fixture = await createFixture("abc") + const wrapped = fixture.input.editBuffer.insertText + await fixture.keys("AX") + fixture.mockInput.pressEscape() + fixture.adapter.cleanup() + expect(fixture.input.editBuffer.insertText).not.toBe(wrapped) + fixture.input.undo() + expect(fixture.input.plainText).toBe("abc") + }) + + test("history from before attachment stays separate from new Vim changes", async () => { + fixture = await createFixture("abc") + await fixture.keys("AX") + fixture.mockInput.pressEscape() + fixture.adapter.cleanup() + await fixture.keys("xuu") + expect(fixture.input.plainText).toBe("abc") + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abcX") + fixture.mockInput.pressKey("r", { ctrl: true }) + expect(fixture.input.plainText).toBe("abc") + }) +}) diff --git a/test/integration/reference.test.ts b/test/integration/reference.test.ts index 67e3cb6..eae12f2 100644 --- a/test/integration/reference.test.ts +++ b/test/integration/reference.test.ts @@ -12,6 +12,16 @@ const cases: Array<[string, string, number?]> = [ ["one two", "cwX"], ["one two", "ciwXw."], ["hello", "AXu"], + ["hello", "AXu"], + ["hello", "AXuu"], + ["中", "AXu"], + ["👩‍💻", "AXu"], + ["one\ntwo", "jddu"], + ["one\ntwo", "jddu"], + ["one\ntwo\nthree", "jddu"], + ["one\ntwo\nthree", "Gddu"], + ["one\ntwo\nthree", "jjddkdd2u"], + ["one\n中abc", "Gddu"], ["one\ntwo", "jccX"], ["one", "ccX"], ["one\ntwo", "ddp"], diff --git a/test/unit/edit.test.ts b/test/unit/edit.test.ts index 32a8e90..1c775e5 100644 --- a/test/unit/edit.test.ts +++ b/test/unit/edit.test.ts @@ -18,10 +18,7 @@ describe("editInput", () => { editInput(fixture.input, "中 [Image 1] ac", "unicode") - expect(fixture.calls).toEqual([ - ["selection", 14, 15], - ["insert", ""], - ]) + expect(fixture.calls).toEqual([["selection", 14, 15], ["delete"]]) }) test("does not split graphemes", () => { @@ -38,10 +35,7 @@ describe("editInput", () => { test("uses the terminal width method when deleting or inserting after a joined emoji", () => { const deletion = createFixture("a👩‍💻b") editInput(deletion.input, "ab", "wcwidth") - expect(deletion.calls).toEqual([ - ["selection", 1, 5], - ["insert", ""], - ]) + expect(deletion.calls).toEqual([["selection", 1, 5], ["delete"]]) const insertion = createFixture("a👩‍💻b") editInput(insertion.input, "a👩‍💻xb", "wcwidth") @@ -70,7 +64,7 @@ describe("editInput", () => { }) function createFixture(plainText: string) { - const calls: Array<["cursor", number] | ["selection", number, number] | ["insert", string]> = [] + const calls: Array<["cursor", number] | ["selection", number, number] | ["insert", string] | ["delete"]> = [] let cursorOffset = 0 const input = { plainText, @@ -88,6 +82,9 @@ function createFixture(plainText: string) { insertText(text: string) { calls.push(["insert", text]) }, + deleteChar() { + calls.push(["delete"]) + }, } return { input, calls } } From 3764633983a56fe57ab494c93824ef47d4c56daa Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 3 Oct 2026 14:10:47 -0400 Subject: [PATCH 3/3] feat: add subagent response reader --- README.md | 3 + src/readers/index.tsx | 7 ++ src/readers/subagent/data.ts | 75 ++++++++++++++ src/readers/subagent/index.tsx | 33 +++++++ src/transcript-items.tsx | 14 ++- test/README.md | 1 + test/e2e/data/subagent.ts | 80 +++++++++++++++ test/e2e/run.ts | 8 ++ test/e2e/scenarios/subagent-reader.ts | 70 +++++++++++++ test/integration/readers/subagent.test.ts | 24 +++++ test/unit/subagent-reader.test.ts | 115 ++++++++++++++++++++++ 11 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/readers/subagent/data.ts create mode 100644 src/readers/subagent/index.tsx create mode 100644 test/e2e/data/subagent.ts create mode 100644 test/e2e/scenarios/subagent-reader.ts create mode 100644 test/integration/readers/subagent.test.ts create mode 100644 test/unit/subagent-reader.test.ts diff --git a/README.md b/README.md index ef5c9c9..42bc5de 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Press `Esc` to enter normal mode and `i` to type again in insert mode. In a session, press `s` from normal mode to browse messages and tools. Use `j`/`k` to navigate, `Enter` to open an item, and `s` to return to the prompt. +Subagent entries open the full saved response, with Vim navigation, selection, +and copying. Background completion entries use the same reader. + Use `/vim` to toggle the plugin on or off. ## Documentation diff --git a/src/readers/index.tsx b/src/readers/index.tsx index ef20194..b1f2dd4 100644 --- a/src/readers/index.tsx +++ b/src/readers/index.tsx @@ -5,11 +5,18 @@ import { ReadReader } from "./read" import { readSnapshot } from "./read/data" import { DiffReader } from "./diff" import { diffSnapshot } from "./diff/data" +import { SubagentReader } from "./subagent" import type { ReaderProps } from "./types" export function Reader(props: ReaderProps) { props.context.ui.dialog.set({ size: "large", centered: true }) const source = props.message.source + if ( + (source?.type === "tool" && source.name === "subagent") || + (source?.type === "synthetic" && source.metadata?.source === "subagent") + ) { + return + } if ( source?.type === "shell" || (source?.type === "tool" && source.name === "shell") || diff --git a/src/readers/subagent/data.ts b/src/readers/subagent/data.ts new file mode 100644 index 0000000..8f0ac70 --- /dev/null +++ b/src/readers/subagent/data.ts @@ -0,0 +1,75 @@ +import type { Context } from "@opencode/plugin/tui/context" +import type { TranscriptSource } from "../../transcript-items" + +export type SubagentSource = Extract +type Message = ReturnType[number] + +export function subagentSnapshot( + source: SubagentSource, + messages: readonly Message[] = [], +): { description: string; status: string; response: string } { + if (source.type === "synthetic") { + const metadata = source.metadata ?? {} + const description = source.description ?? "" + const prefix = `\n` + return { + description, + status: metadata.state === "error" ? "Failed" : metadata.state === "cancelled" ? "Cancelled" : "Completed", + response: unwrap(source.text, prefix), + } + } + + const state = source.state + if (state.status === "streaming") return { description: "", status: "Delegating…", response: "No response yet." } + const description = typeof state.input.description === "string" ? state.input.description : "" + const metadata = state.metadata ?? {} + if (state.status === "completed" && metadata.status === "running") { + // Background replies are saved as separate notices in the parent session. + // Use the first matching completion, not a later continuation's response. + for (const message of messages) { + if (message.type === "assistant") { + for (const part of message.content) { + if ( + part.type === "tool" && + part.name === "subagent" && + part.time.created > source.time.created && + part.state.status !== "streaming" && + part.state.input.sessionID === metadata.sessionID + ) + return { description, status: "Backgrounded", response: "No saved response for this subagent call." } + } + } + if ( + message.type === "synthetic" && + message.metadata?.source === "subagent" && + typeof metadata.sessionID === "string" && + message.metadata.childID === metadata.sessionID && + message.description === description && + message.time.created >= source.time.created + ) + return subagentSnapshot(message) + } + return { + description, + status: "Backgrounded", + response: "No saved response yet. Reopen after the subagent finishes.", + } + } + if (state.status === "running") return { description, status: "Running", response: "No response yet." } + + const prefix = `\n` + let response = "" + for (const part of state.content ?? []) { + if (part.type !== "text") continue + if (response) response += "\n\n" + response += unwrap(part.text, prefix) + } + if (state.status === "error") return { description, status: "Failed", response: response || state.error.message } + return { description, status: "Completed", response: response || "Subagent completed without a text response." } +} + +function unwrap(text: string, prefix: string) { + const suffix = "\n" + if (text.startsWith(prefix) && text.endsWith(suffix)) return text.slice(prefix.length, -suffix.length) + return text +} diff --git a/src/readers/subagent/index.tsx b/src/readers/subagent/index.tsx new file mode 100644 index 0000000..37b6a4c --- /dev/null +++ b/src/readers/subagent/index.tsx @@ -0,0 +1,33 @@ +/** @jsxImportSource @opentui/solid */ +import { Show } from "solid-js" +import { subagentSnapshot, type SubagentSource } from "./data" +import { TextReader } from "../text" +import type { ReaderProps } from "../types" + +export function SubagentReader(props: ReaderProps & { source: SubagentSource }) { + const source = props.source + const messages = + source.type === "tool" && source.state.status === "completed" && source.state.metadata?.status === "running" + ? props.context.data.session.message.list(props.sessionID) + : [] + const snapshot = subagentSnapshot(source, messages) + const theme = () => props.context.theme.surface("dialog") + return ( + + + + {snapshot.description} + + + + } + /> + ) +} diff --git a/src/transcript-items.tsx b/src/transcript-items.tsx index 68dbb7e..e5878de 100644 --- a/src/transcript-items.tsx +++ b/src/transcript-items.tsx @@ -51,17 +51,25 @@ function parts(messages: Message[]) { ) { result.push({ id: `footer:${message.id}`, messageID: message.id, kind: "footer", author: "", text: "" }) } - } else if (message.type === "synthetic" && message.metadata?.source === "shell" && message.description?.trim()) { + } else if ( + message.type === "synthetic" && + (message.metadata?.source === "shell" || message.metadata?.source === "subagent") && + message.description?.trim() + ) { const state = message.metadata.state const status = state === "completed" ? "finished" : state === "error" ? "failed" : (state ?? "finished") + const shell = message.metadata.source === "shell" + const agent = typeof message.metadata.agent === "string" ? message.metadata.agent : "Subagent" + const actor = shell ? "Shell" : agent.replace(/\b\w/g, (letter) => letter.toUpperCase()) + const description = shell ? message.description.replace(/\s+/g, " ").trim() : message.description result.push({ id: message.id, messageID: message.id, kind: "message", - author: "Shell", + author: actor, text: message.text, source: message, - label: `${state === "completed" ? "↳" : "!"} Shell ${status} · ${message.description.replace(/\s+/g, " ").trim()}`, + label: `${state === "completed" ? "↳" : "!"} ${actor} ${status} · ${description}`, }) } else { result.push({ diff --git a/test/README.md b/test/README.md index daf16e2..d8e2ddd 100644 --- a/test/README.md +++ b/test/README.md @@ -45,6 +45,7 @@ configuration fallback stays in unit tests. | Session entry/exit, toggle key scope, mappings, control chords, narrow status layout | `unit/session-keymaps.test.ts` | `session-key-*`, `session-empty`, `session-keymaps`, `session-agent-binding` | | Session and reader lifecycle, route changes, replacement dialogs and disable | `integration/readers/text.test.ts` | `session-lifecycle`, `runtime-*`, `message-reader` (including delayed host refocus) | | Message reader motions, exact copy, read-only mappings/paste, streaming snapshot, paging and remembered positions | `integration/readers/text.test.ts` | `message-reader`, `reader-layout`, `session-copy` | +| Subagent responses, transport wrappers, background completions, continuation boundaries and exact copy | `integration/readers/subagent.test.ts`, `unit/subagent-reader.test.ts` | `subagent-reader*` (normal and compact tool groups, completion rows and prompt restoration) | | Shell sections, panel mappings, whitespace, Unicode and delayed syntax/diff highlights | `integration/readers/shell.test.ts` | `shell-reader*`, `background-shell*`, `session-keymaps` | | File reader ranges, gutters, partial/empty/non-file results, highlighting and disposal | `integration/readers/read.test.ts`, `unit/read-reader.test.ts` | `read-reader*` (including native modal resize) | | Change views, per-view cursors, signs/colors, exact copy, mouse tabs, added/deleted/unknown patches | `integration/readers/diff.test.ts`, `unit/diff-reader.test.ts` | `diff-reader*`, `file-changes*` (including native modal resize) | diff --git a/test/e2e/data/subagent.ts b/test/e2e/data/subagent.ts new file mode 100644 index 0000000..5215586 --- /dev/null +++ b/test/e2e/data/subagent.ts @@ -0,0 +1,80 @@ +import type { Message } from "../support/fixture" + +export const subagentMessages: Message[] = [ + { id: "msg_subagent_prompt", type: "user", text: "Check subagent tool access", time: { created: 1 } }, + { + id: "msg_subagent_foreground", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "fixture" }, + time: { created: 2, completed: 3 }, + finish: "stop", + content: [ + { + type: "tool", + id: "foreground", + name: "subagent", + time: { created: 2, completed: 3 }, + state: { + status: "completed", + input: { + agent: "general", + description: "Check question tool access", + prompt: "Do you have a question tool?", + }, + metadata: { sessionID: "ses_foreground", status: "completed" }, + content: [ + { + type: "text", + text: '\nNo. There is no question tool in my available tool list.\n\nAvailable tools: read, grep, shell.\n', + }, + ], + }, + }, + { type: "text", text: "Parent summary of the foreground result" }, + ], + }, + { + id: "msg_subagent_background", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "fixture" }, + time: { created: 4, completed: 5 }, + finish: "stop", + content: [ + { + type: "tool", + id: "background", + name: "subagent", + time: { created: 4, completed: 5 }, + state: { + status: "completed", + input: { + agent: "general", + description: "Check background tool access", + prompt: "List your tools", + background: true, + }, + metadata: { sessionID: "ses_background", status: "running" }, + content: [{ type: "text", text: "The subagent is working in the background" }], + }, + }, + { type: "text", text: "Background task launched" }, + ], + }, + { + id: "msg_subagent_hidden", + type: "synthetic", + text: "Hidden fixture context", + time: { created: 6 }, + metadata: { source: "fixture" }, + }, + { + id: "msg_subagent_completion", + type: "synthetic", + description: "Check background tool access", + time: { created: 7 }, + metadata: { source: "subagent", childID: "ses_background", agent: "general", state: "completed" }, + text: '\nBackground response: read, grep, shell.\n', + }, +] diff --git a/test/e2e/run.ts b/test/e2e/run.ts index 89296e7..ece14c0 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -9,6 +9,8 @@ import { agentSwitching } from "./scenarios/agent-switching" import { dialogFocus, dialogMappings, dialogModeInheritance, dialogScope, promptDialog } from "./scenarios/dialog-focus" import { tabSwitching } from "./scenarios/tab-switching" import { messageReader, readerLayout } from "./scenarios/message-reader" +import { subagentReader } from "./scenarios/subagent-reader" +import { subagentMessages } from "./data/subagent" import { transcriptGrouped, transcriptLowDetail, @@ -167,6 +169,12 @@ const scenarios: Array<{ }, { name: "message-reader", run: messageReader, setup: { messages: readerMessages } }, { name: "reader-layout", run: readerLayout, setup: { messages: longReaderMessages } }, + { name: "subagent-reader", run: subagentReader, setup: { messages: subagentMessages } }, + { + name: "subagent-reader-low-detail", + run: subagentReader, + setup: { messages: subagentMessages, cli: { session: { verbosity: "low" } } }, + }, { name: "session-empty", run: emptySession }, { name: "session-lifecycle", diff --git a/test/e2e/scenarios/subagent-reader.ts b/test/e2e/scenarios/subagent-reader.ts new file mode 100644 index 0000000..6cd86e9 --- /dev/null +++ b/test/e2e/scenarios/subagent-reader.ts @@ -0,0 +1,70 @@ +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" + +export async function subagentReader({ terminal }: Fixture) { + const { type, keys, screen } = terminal + await type("subagent draft") + await keys("Escape") + await type("s") + await screen("completion-selected", (text) => selected(text, "General finished")) + await keys("Enter") + await screen( + "completion-response", + (text) => text.includes("Subagent response") && readerContains(text, "Background response: read, grep, shell."), + ) + await keys("Escape") + await screen("completion-closed", (text) => !text.includes("v select") && selected(text, "General finished")) + await type("2k") + let grouped = false + await screen("background-selected", (text) => { + grouped = selected(text, "1 tool") + return grouped || selected(text, "General Subagent — Check background tool access") + }) + if (grouped) { + await keys("Enter") + await screen("background-expanded", (text) => text.includes("General Subagent — Check background tool access")) + await type("j") + await screen("background-tool-selected", (text) => + selected(text, "General Subagent — Check background tool access"), + ) + } + await keys("Enter") + await screen( + "background-response", + (text) => + readerContains(text, "Background response: read, grep, shell.") && + !readerContains(text, "working in the background"), + ) + await keys("Escape") + await screen( + "background-closed", + (text) => !text.includes("v select") && selected(text, "Check background tool access"), + ) + await type("ggj") + await screen("foreground-selected", (text) => + selected(text, grouped ? "1 tool" : "General Subagent — Check question tool access"), + ) + if (grouped) { + await keys("Enter") + await screen("foreground-expanded", (text) => text.includes("General Subagent — Check question tool access")) + await type("j") + await screen("foreground-tool-selected", (text) => selected(text, "General Subagent — Check question tool access")) + } + await keys("Enter") + await screen( + "foreground-response", + (text) => + readerContains(text, "No. There is no question tool in my available tool list.") && + readerContains(text, "Available tools: read, grep, shell.") && + !readerContains(text, " text.includes("Copied")) + await keys("Escape", "Enter") + await screen("foreground-reopened", (text) => readerContains(text, "Available tools: read, grep, shell.")) + await type("sA restored") + await screen( + "prompt-restored", + (text) => text.includes("subagent draft restored") && text.includes("INSERT") && !text.includes("SESSION"), + ) +} diff --git a/test/integration/readers/subagent.test.ts b/test/integration/readers/subagent.test.ts new file mode 100644 index 0000000..6d237ac --- /dev/null +++ b/test/integration/readers/subagent.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test" +import { toolItem, useReaderFixture } from "../../helpers/reader" + +const mount = useReaderFixture() + +test("subagent reader focuses and copies the full response instead of the history label", async () => { + const response = "No. There is no question tool in my available tool list.\n\nAvailable tools: read, grep, shell." + const message = toolItem("subagent", "General Subagent — Check question tool access", { + status: "completed", + input: { agent: "general", description: "Check question tool access", prompt: "Do you have a question tool?" }, + metadata: { sessionID: "ses_child", status: "completed" }, + content: [{ type: "text", text: `\n${response}\n` }], + }) + const f = await mount(message) + expect(f.reader().plainText).toBe(response) + expect(f.renderer.currentFocusedEditor).toBe(f.reader()) + expect(f.captureCharFrame()).toContain("Subagent response") + expect(f.captureCharFrame()).toContain("Check question tool access") + expect(f.captureCharFrame()).toContain("Completed") + await f.keys("ggVGy") + expect(f.copied.at(-1)).toBe(response + "\n") + await f.keys("x") + expect(f.reader().plainText).toBe(response) +}) diff --git a/test/unit/subagent-reader.test.ts b/test/unit/subagent-reader.test.ts new file mode 100644 index 0000000..7aa49f1 --- /dev/null +++ b/test/unit/subagent-reader.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test" +import { subagentSnapshot, type SubagentSource } from "../../src/readers/subagent/data" + +type Tool = Extract +function tool(state: Tool["state"]): Tool { + return { type: "tool", id: "call", name: "subagent", time: { created: 10 }, state } +} + +function completion(time: number, response: string): Extract { + return { + type: "synthetic", + id: `msg_${time}`, + time: { created: time }, + description: "Check tools", + metadata: { source: "subagent", childID: "ses_child", state: "completed", agent: "general" }, + text: `\n${response}\n`, + } +} + +test("subagent responses remove only the transport wrapper and preserve Markdown and whitespace", () => { + const response = "# Findings\n\n indented \nquoted example\n" + expect( + subagentSnapshot( + tool({ + status: "completed", + input: { description: "Check tools" }, + metadata: { sessionID: "ses_child", status: "completed" }, + content: [ + { type: "text", text: `\n${response}\n` }, + ], + }), + ), + ).toEqual({ description: "Check tools", status: "Completed", response }) + expect(subagentSnapshot(completion(11, response)).response).toBe(response) +}) + +test("plain results and multiple text parts stay readable", () => { + expect( + subagentSnapshot( + tool({ + status: "completed", + input: {}, + content: [ + { type: "text", text: "First finding" }, + { type: "text", text: "Second finding" }, + ], + }), + ).response, + ).toBe("First finding\n\nSecond finding") +}) + +test("unfinished, empty and failed calls explain why there is no response", () => { + expect(subagentSnapshot(tool({ status: "streaming", input: '{"agent":' }))).toMatchObject({ + status: "Delegating…", + response: "No response yet.", + }) + expect(subagentSnapshot(tool({ status: "running", input: {}, metadata: {} }))).toMatchObject({ + status: "Running", + response: "No response yet.", + }) + expect( + subagentSnapshot(tool({ status: "completed", input: {}, content: [{ type: "text", text: "" }] })).response, + ).toContain("without a text response") + expect( + subagentSnapshot(tool({ status: "error", input: {}, error: { type: "tool", message: "Agent unavailable" } })), + ).toMatchObject({ + status: "Failed", + response: "Agent unavailable", + }) + const notice = completion(11, "Saved error") + notice.metadata!.state = "error" + notice.text = "Saved error" + expect(subagentSnapshot(notice)).toMatchObject({ status: "Failed", response: "Saved error" }) + notice.metadata!.state = "cancelled" + expect(subagentSnapshot(notice).status).toBe("Cancelled") +}) + +test("background calls use their saved completion rather than an earlier or later response", () => { + const source = tool({ + status: "completed", + input: { description: "Check tools", background: true }, + metadata: { sessionID: "ses_child", status: "running" }, + content: [{ type: "text", text: "The subagent is working in the background" }], + }) + const other = completion(11, "Unrelated response") + other.metadata!.childID = "ses_other" + const messages = [ + completion(2, "Earlier response"), + other, + completion(12, "Selected response"), + completion(20, "Later response"), + ] + expect(subagentSnapshot(source, messages)).toMatchObject({ status: "Completed", response: "Selected response" }) + expect(subagentSnapshot(source, messages.slice(0, 2))).toMatchObject({ status: "Backgrounded" }) + expect(subagentSnapshot(source, messages.slice(0, 2)).response).toContain("No saved response yet") + const continuation = tool({ + status: "running", + input: { sessionID: "ses_child", description: "Check tools" }, + metadata: { sessionID: "ses_child" }, + }) + continuation.time.created = 15 + expect( + subagentSnapshot(source, [ + { + type: "assistant", + id: "msg_continuation", + agent: "build", + model: { providerID: "test", id: "fixture" }, + time: { created: 15 }, + content: [continuation], + }, + completion(20, "Later response"), + ]), + ).toMatchObject({ status: "Backgrounded", response: "No saved response for this subagent call." }) +})