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/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/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/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/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 4bf39d3..d8e2ddd 100644
--- a/test/README.md
+++ b/test/README.md
@@ -30,29 +30,31 @@ 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` |
+| 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` |
+| 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) |
+| 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 +76,44 @@ 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`.
+
+## 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
+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/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 42c937f..ece14c0 100644
--- a/test/e2e/run.ts
+++ b/test/e2e/run.ts
@@ -1,13 +1,16 @@
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"
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,
@@ -31,6 +34,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"
@@ -58,6 +62,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,
@@ -164,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",
@@ -342,6 +353,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 +364,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 +439,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 +451,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/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/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/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/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/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/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 }
}
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." })
+})
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)
+ })
+})