feat(opencode): add interactive split-footer mode to run (#23557)

This commit is contained in:
Simon Klee
2026-05-08 12:17:14 +02:00
committed by GitHub
parent 15784aa036
commit 7f2b5ee8c2
60 changed files with 21850 additions and 347 deletions

View File

@@ -0,0 +1,483 @@
import { describe, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
}
function toolPart(tool: string, state: ToolPart["state"], id = `${tool}-1`, messageID = `msg-${tool}`): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
state: ToolPart["state"]
phase?: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
id?: string
messageID?: string
}) {
return commit({
kind: "tool",
text: input.text ?? "",
phase: input.phase ?? "final",
source: "tool",
tool: input.tool,
toolState: input.toolState ?? "completed",
part: toolPart(input.tool, input.state, input.id, input.messageID),
})
}
function structured(next: StreamCommit) {
const body = entryBody(next)
expect(body.type).toBe("structured")
if (body.type !== "structured") {
throw new Error("expected structured body")
}
return body.snapshot
}
describe("run entry body", () => {
test("renders assistant, reasoning, and user entries in their display formats", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "# Title\n\nHello **world**",
phase: "progress",
source: "assistant",
partID: "part-1",
}),
),
).toEqual({
type: "markdown",
content: "# Title\n\nHello **world**",
})
const reasoning = entryBody(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
partID: "reason-1",
}),
)
expect(reasoning).toEqual({
type: "code",
filetype: "markdown",
content: "_Thinking:_ plan next steps",
})
expect(
entryCanStream(
commit({
kind: "reasoning",
text: "Thinking: plan next steps",
phase: "progress",
source: "reasoning",
}),
reasoning,
),
).toBe(true)
expect(
entryBody(
commit({
kind: "user",
text: "Inspect footer tabs",
phase: "start",
source: "system",
}),
),
).toEqual({
type: "text",
content: " Inspect footer tabs",
})
})
for (const item of [
{
name: "keeps completed write tool finals structured",
commit: toolCommit({
tool: "write",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\n",
},
output: "",
title: "",
metadata: {},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "code",
title: "# Wrote src/a.ts",
content: "const x = 1\n",
file: "src/a.ts",
},
},
{
name: "keeps completed edit tool finals structured",
commit: toolCommit({
tool: "edit",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
},
output: "",
title: "",
metadata: {
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Edited src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
},
],
},
},
{
name: "keeps completed apply_patch tool finals structured",
commit: toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
snapshot: {
kind: "diff",
items: [
{
title: "# Patched src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
file: "src/a.ts",
deletions: 0,
},
],
},
},
] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
test(item.name, () => {
expect(structured(item.commit)).toEqual(item.snapshot)
})
}
test("keeps running task tool state out of scrollback", () => {
expect(
entryBody(
toolCommit({
tool: "task",
phase: "start",
toolState: "running",
text: "running inspect reducer",
state: {
status: "running",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
time: { start: 1 },
},
}),
),
).toEqual({
type: "none",
})
})
test("promotes task results to markdown and falls back to structured task summaries", () => {
expect(
entryBody(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"# Findings\n\n- Footer stays live",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "markdown",
content: "# Findings\n\n- Footer stays live",
})
expect(
structured(
toolCommit({
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
kind: "task",
title: "# Explore Task",
rows: ["Inspect reducer"],
tail: "",
})
})
test("streams tool progress text and treats completed progress as done", () => {
const body = entryBody(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
partID: "tool-2",
}),
)
expect(body).toEqual({
type: "text",
content: "partial output",
})
expect(
entryCanStream(
commit({
kind: "tool",
text: "partial output",
phase: "progress",
source: "tool",
tool: "bash",
}),
body,
),
).toBe(true)
expect(
entryDone(
commit({
kind: "tool",
text: "output",
phase: "progress",
source: "tool",
tool: "bash",
toolState: "completed",
}),
),
).toBe(true)
})
test("formats completed bash output with a blank line after the command and no trailing blank row", () => {
expect(
entryBody(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: [
"/tmp/demo",
"git status",
"On branch demo",
"nothing to commit, working tree clean",
"",
].join("\n"),
state: {
status: "completed",
input: {
command: "git status",
workdir: "/tmp/demo",
},
output: [
"/tmp/demo",
"git status",
"On branch demo",
"nothing to commit, working tree clean",
"",
].join("\n"),
title: "git status",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "\nOn branch demo\nnothing to commit, working tree clean",
})
})
test("falls back to patch summary when apply_patch has no visible diff items", () => {
expect(
entryBody(
toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
],
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "~ Patched src/a.ts",
})
})
test("suppresses redundant patched rows when apply_patch also created a file", () => {
expect(
entryBody(
toolCommit({
tool: "apply_patch",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "",
metadata: {
files: [
{
type: "update",
filePath: "src/a.ts",
relativePath: "src/a.ts",
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
{
type: "add",
filePath: "README-demo.md",
relativePath: "README-demo.md",
},
],
},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "+ Created README-demo.md",
})
})
test("renders glob failures as the raw error under the existing header", () => {
expect(
entryBody(
toolCommit({
tool: "glob",
phase: "final",
toolState: "error",
state: {
status: "error",
input: {
pattern: "**/*tool*",
path: "/tmp/demo/run",
},
error: "No such file or directory: '/tmp/demo/run'",
metadata: {},
time: { start: 1, end: 2 },
},
}),
),
).toEqual({
type: "text",
content: "No such file or directory: '/tmp/demo/run'",
})
})
test("renders interrupted assistant finals as text", () => {
expect(
entryBody(
commit({
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
interrupted: true,
partID: "part-1",
}),
),
).toEqual({
type: "text",
content: "assistant interrupted",
})
})
})

View File

@@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu"
function mount(count: number, limit = FOOTER_MENU_ROWS) {
let dispose!: () => void
let menu!: ReturnType<typeof createFooterMenuState>
createRoot((nextDispose) => {
dispose = nextDispose
menu = createFooterMenuState({ count: () => count, limit })
return null
})
return { menu, dispose }
}
test("footer menu scrolls before the selected row hits the bottom edge", () => {
const state = mount(20)
try {
Array.from({ length: 6 }).forEach(() => state.menu.move(1))
expect(state.menu.selected()).toBe(6)
expect(state.menu.offset()).toBe(1)
} finally {
state.dispose()
}
})
test("footer menu scrolls before the selected row hits the top edge", () => {
const state = mount(20)
try {
Array.from({ length: 13 }).forEach(() => state.menu.move(1))
Array.from({ length: 4 }).forEach(() => state.menu.move(-1))
expect(state.menu.selected()).toBe(9)
expect(state.menu.offset()).toBe(7)
} finally {
state.dispose()
}
})

View File

@@ -0,0 +1,273 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RUN_COMMAND_PANEL_ROWS, RunCommandMenuBody, RunModelSelectBody, RunVariantSelectBody } from "@/cli/cmd/run/footer.command"
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { FooterKeybinds, RunCommand, RunInput, RunProvider, StreamCommit } from "@/cli/cmd/run/types"
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
const keybinds: FooterKeybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t"),
interrupt: bindings("escape"),
historyPrevious: bindings("up"),
historyNext: bindings("down"),
inputClear: bindings("ctrl+c"),
inputSubmit: bindings("return"),
inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
}
function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
return {
name: input.name,
description: input.description,
source: input.source,
template: "",
hints: [],
} satisfies RunCommand
}
function model(input: {
id: string
name: string
status?: "active" | "deprecated"
cost?: number
variants?: Record<string, Record<string, never>>
}) {
return {
id: input.id,
providerID: "opencode",
api: {
id: "opencode",
url: "https://opencode.ai",
npm: "@ai-sdk/openai-compatible",
},
name: input.name,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: true,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: input.cost ?? 1,
output: 1,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: input.status ?? "active",
options: {},
headers: {},
release_date: "2026-01-01",
variants: input.variants,
} satisfies RunProvider["models"][string]
}
function provider() {
return {
id: "opencode",
name: "opencode",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model({ id: "gpt-5", name: "GPT-5", variants: { high: {}, minimal: {} } }),
"gpt-free": model({ id: "gpt-free", name: "GPT Free", cost: 0 }),
old: model({ id: "old", name: "Old Model", status: "deprecated" }),
},
} satisfies RunProvider
}
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
text: "I",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
const app = await testRender(() => (
<box width={80} height={4}>
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
</box>
), {
width: 80,
height: 4,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I")
setCommit({
kind: "tool",
text: "I need to inspect the codebase",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I need to inspect the codebase")
} finally {
app.renderer.destroy()
}
})
test("direct command panel renders grouped command palette", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
])
const [variants] = createSignal(["high", "minimal"])
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunCommandMenuBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
variants={variants}
keybinds={keybinds}
onClose={() => {}}
onModel={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
onNew={() => {}}
onExit={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Commands")
expect(frame).toContain("Search")
expect(frame).toContain("Suggested")
expect(frame).toContain("Switch model")
expect(frame).toContain("Variant cycle")
expect(frame).toContain("ctrl+t")
expect(frame).toContain("Switch model variant")
expect(frame).toContain("Session")
expect(frame).toContain("New session")
expect(frame).toContain("/new")
expect(frame).toContain("Project Commands")
expect(frame).toContain("review")
expect(frame).toContain("/review")
expect(frame).not.toContain("/internal")
expect(frame).not.toContain("Choose model for future turns")
expect(frame).not.toContain("Cycle reasoning effort for future turns")
expect(frame).not.toContain("Review code")
expect(frame).not.toContain("Commands 8")
} finally {
app.renderer.destroy()
}
})
test("direct model panel renders current model selector", async () => {
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunModelSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
providers={providers}
current={current}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Select model")
expect(frame).toContain("Search")
expect(frame).toContain("opencode")
expect(frame).toContain("GPT-5")
expect(frame).toContain("current")
expect(frame).toContain("GPT Free")
expect(frame).toContain("Free")
expect(frame).not.toContain("Old Model")
} finally {
app.renderer.destroy()
}
})
test("direct variant panel renders current variant selector", async () => {
const [variants] = createSignal(["high", "minimal"])
const [current] = createSignal<string | undefined>("high")
const app = await testRender(() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunVariantSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
variants={variants}
current={current}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
), {
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Select variant")
expect(frame).toContain("Default")
expect(frame).toContain("high")
expect(frame).toContain("minimal")
expect(frame).toContain("current")
} finally {
app.renderer.destroy()
}
})

View File

@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import {
createPermissionBodyState,
permissionAlwaysLines,
permissionCancel,
permissionEscape,
permissionInfo,
permissionReject,
permissionRun,
} from "@/cli/cmd/run/permission.shared"
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
return {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: [],
metadata: {},
always: [],
...input,
}
}
describe("run permission shared", () => {
test("replies immediately for allow once", () => {
const out = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "once")
expect(out.reply).toEqual({
requestID: "perm-1",
reply: "once",
})
})
test("requires confirmation for allow always", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "always")
expect(next.state.stage).toBe("always")
expect(next.state.selected).toBe("confirm")
expect(next.reply).toBeUndefined()
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
requestID: "perm-1",
reply: "always",
})
expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("builds trimmed reject replies and stage transitions", () => {
const next = permissionRun(createPermissionBodyState("perm-1"), "perm-1", "reject")
expect(next.state.stage).toBe("reject")
const out = permissionReject({ ...next.state, message: " use rg " }, "perm-1")
expect(out).toEqual({
requestID: "perm-1",
reply: "reject",
message: "use rg",
})
expect(permissionCancel(next.state)).toMatchObject({
stage: "permission",
selected: "reject",
})
expect(permissionEscape(createPermissionBodyState("perm-1"))).toMatchObject({
stage: "reject",
selected: "reject",
})
expect(permissionEscape({ ...next.state, stage: "always", selected: "confirm" })).toMatchObject({
stage: "permission",
selected: "always",
})
})
test("maps supported permission types into display info", () => {
expect(
permissionInfo(
req({
permission: "bash",
metadata: {
input: {
command: "git status --short",
},
},
}),
),
).toMatchObject({
title: "Shell command",
lines: ["$ git status --short"],
})
expect(
permissionInfo(
req({
permission: "task",
metadata: {
description: "investigate stream",
subagent_type: "general",
},
}),
),
).toMatchObject({
title: "General Task",
lines: ["◉ investigate stream"],
})
expect(
permissionInfo(
req({
permission: "external_directory",
patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
}),
),
).toMatchObject({
title: "Access external directory /tmp/work",
lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
})
expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({
title: "Continue after repeated failures",
})
expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
title: "Call tool custom_tool",
lines: ["Tool: custom_tool"],
})
})
test("formats always-allow copy for wildcard and explicit patterns", () => {
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
"This will allow bash until OpenCode is restarted.",
])
expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
"This will allow the following patterns until OpenCode is restarted.",
"- src/**/*.ts",
"- src/**/*.tsx",
])
})
})

View File

@@ -0,0 +1,132 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isExitCommand,
isNewCommand,
movePromptHistory,
printableBinding,
promptCycle,
promptHit,
promptInfo,
promptKeys,
pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared"
import type { RunPrompt } from "@/cli/cmd/run/types"
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
const keybinds = {
leader: "ctrl+x",
leaderTimeout: 2000,
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t", "<leader>t"),
interrupt: bindings("escape"),
historyPrevious: bindings("up"),
historyNext: bindings("down"),
inputClear: bindings("ctrl+c"),
inputSubmit: bindings("return"),
inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
}
function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
return { text, parts }
}
describe("run prompt shared", () => {
test("filters blank prompts and dedupes consecutive history", () => {
const out = createPromptHistory([prompt(" "), prompt("one"), prompt("one"), prompt("two"), prompt("one")])
expect(out.items.map((item) => item.text)).toEqual(["one", "two", "one"])
expect(out.index).toBeNull()
expect(out.draft).toBe("")
})
test("push ignores blanks and dedupes only the latest item", () => {
const base = createPromptHistory([prompt("one")])
expect(pushPromptHistory(base, prompt(" ")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("one")).items.map((item) => item.text)).toEqual(["one"])
expect(pushPromptHistory(base, prompt("two")).items.map((item) => item.text)).toEqual(["one", "two"])
})
test("moves through history only at input boundaries and restores draft", () => {
const base = createPromptHistory([prompt("one"), prompt("two")])
expect(movePromptHistory(base, -1, "draft", 1)).toEqual({
state: base,
apply: false,
})
const up = movePromptHistory(base, -1, "draft", 0)
expect(up.apply).toBe(true)
expect(up.text).toBe("two")
expect(up.cursor).toBe(0)
expect(up.state.index).toBe(1)
expect(up.state.draft).toBe("draft")
const older = movePromptHistory(up.state, -1, "two", 0)
expect(older.apply).toBe(true)
expect(older.text).toBe("one")
expect(older.cursor).toBe(0)
expect(older.state.index).toBe(0)
const newer = movePromptHistory(older.state, 1, "one", 3)
expect(newer.apply).toBe(true)
expect(newer.text).toBe("two")
expect(newer.cursor).toBe(3)
expect(newer.state.index).toBe(1)
const draft = movePromptHistory(newer.state, 1, "two", 3)
expect(draft.apply).toBe(true)
expect(draft.text).toBe("draft")
expect(draft.cursor).toBe(5)
expect(draft.state.index).toBeNull()
})
test("handles direct and leader-based variant cycling", () => {
const keys = promptKeys(keybinds)
expect(promptHit(keys.clear, promptInfo({ name: "c", ctrl: true }))).toBe(true)
expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: true,
clear: false,
cycle: false,
consume: true,
})
expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: true,
cycle: true,
consume: true,
})
expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
arm: false,
clear: false,
cycle: true,
consume: true,
})
})
test("prints bindings with leader substitution and esc normalization", () => {
expect(printableBinding(keybinds.variantCycle.slice(1), "ctrl+x")).toBe("ctrl+x t")
expect(printableBinding(keybinds.interrupt, "ctrl+x")).toBe("esc")
expect(printableBinding([], "ctrl+x")).toBe("")
})
test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true)
expect(isExitCommand("/quit now")).toBe(false)
})
test("recognizes the new-session command", () => {
expect(isNewCommand("/new")).toBe(true)
expect(isNewCommand(" /NEW ")).toBe(true)
expect(isNewCommand("/new now")).toBe(false)
})
})

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import {
createQuestionBodyState,
questionConfirm,
questionReject,
questionSave,
questionSelect,
questionSetSelected,
questionStoreCustom,
questionSubmit,
questionSync,
} from "@/cli/cmd/run/question.shared"
function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
return {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
...input,
}
}
describe("run question shared", () => {
test("replies immediately for a single-select question", () => {
const out = questionSelect(createQuestionBodyState("question-1"), req())
expect(out.reply).toEqual({
requestID: "question-1",
answers: [["chunked"]],
})
})
test("advances multi-question flows and submits from confirm", () => {
const ask = req({
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
{
question: "Output?",
header: "Output",
options: [
{ label: "yes", description: "Show tool output" },
{ label: "no", description: "Hide tool output" },
],
multiple: false,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.tab).toBe(1)
state = questionSetSelected(state, 1)
state = questionSelect(state, ask).state
expect(questionConfirm(ask, state)).toBe(true)
expect(questionSubmit(ask, state)).toEqual({
requestID: "question-1",
answers: [["chunked"], ["no"]],
})
})
test("toggles answers for multiple-choice questions", () => {
const ask = req({
questions: [
{
question: "Tags?",
header: "Tags",
options: [{ label: "bug", description: "Bug fix" }],
multiple: true,
},
],
})
let state = questionSelect(createQuestionBodyState("question-1"), ask).state
expect(state.answers).toEqual([["bug"]])
state = questionSelect(state, ask).state
expect(state.answers).toEqual([[]])
})
test("stores and submits custom answers", () => {
let state = questionSetSelected(createQuestionBodyState("question-1"), 1)
let next = questionSelect(state, req())
expect(next.state.editing).toBe(true)
state = questionStoreCustom(next.state, 0, " custom mode ")
next = questionSave(state, req())
expect(next.reply).toEqual({
requestID: "question-1",
answers: [["custom mode"]],
})
})
test("resets state when the request id changes and builds reject payloads", () => {
const state = questionSetSelected(createQuestionBodyState("question-1"), 1)
expect(questionSync(state, "question-1")).toBe(state)
expect(questionSync(state, "question-2")).toEqual(createQuestionBodyState("question-2"))
expect(questionReject(req())).toEqual({
requestID: "question-1",
})
})
})

View File

@@ -0,0 +1,303 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema"
import { ConfigKeybinds } from "@/config/keybinds"
import {
resolveDiffStyle,
resolveFooterKeybinds,
resolveModelInfo,
} from "@/cli/cmd/run/runtime.boot"
type RunBinding = Binding<Renderable, KeyEvent>
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
return {
id,
providerID,
api: {
id: providerID,
url: `https://${providerID}.test`,
npm: `@ai-sdk/${providerID}`,
},
name: id,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context,
output: 8192,
},
status: "active" as const,
options: {},
headers: {},
release_date: "2026-01-01",
variants,
}
}
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
function config(input?: {
leader?: string
leaderTimeout?: number
diff_style?: "auto" | "stacked"
bindings?: Partial<{
commandList: RunBinding[]
variantCycle: RunBinding[]
interrupt: RunBinding[]
historyPrevious: RunBinding[]
historyNext: RunBinding[]
inputClear: RunBinding[]
inputSubmit: RunBinding[]
inputNewline: RunBinding[]
}>
}): Resolved {
const bind = input?.bindings
const sections = {
global: Object.fromEntries([
...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []),
...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []),
]),
prompt: Object.fromEntries([
...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []),
...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []),
...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []),
...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []),
]),
input: Object.fromEntries([
...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []),
...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []),
]),
} satisfies BindingSectionsConfig<Renderable, KeyEvent>
return {
diff_style: input?.diff_style,
keybinds: ConfigKeybinds.Keybinds.parse({}),
keymap: {
leader: input?.leader ?? "ctrl+x",
leader_timeout: input?.leaderTimeout ?? 2000,
...resolveBindingSections<Renderable, KeyEvent, typeof sections, KeymapSection>(sections, {
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
}),
},
}
}
describe("run runtime boot", () => {
afterEach(() => {
mock.restore()
})
test("reads footer keybinds from resolved keymap config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(
config({
leader: "ctrl+g",
bindings: {
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t", "alt+t"),
interrupt: bindings("ctrl+c"),
historyPrevious: bindings("k"),
historyNext: bindings("j"),
inputClear: bindings("ctrl+l"),
inputSubmit: bindings("ctrl+s"),
inputNewline: bindings("alt+return"),
},
}),
)
const result = await resolveFooterKeybinds()
expect(result.leader).toBe("ctrl+g")
expect(result.leaderTimeout).toBe(2000)
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
expect(formatBindings(result.historyNext, result.leader)).toBe("j")
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
})
test("falls back to default keybinds when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
const result = await resolveFooterKeybinds()
expect(result.leader).toBe("ctrl+x")
expect(result.leaderTimeout).toBe(2000)
expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t")
expect(formatBindings(result.interrupt, result.leader)).toBe("esc")
expect(formatBindings(result.historyPrevious, result.leader)).toBe("up")
expect(formatBindings(result.historyNext, result.leader)).toBe("down")
expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c")
expect(formatBindings(result.inputSubmit, result.leader)).toBe("return")
expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j")
})
test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
await expect(resolveDiffStyle()).resolves.toBe("stacked")
mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto")
})
test("prefers configured providers for model selector data", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
const configured = {
providers: [data.all[0]!],
default: {},
}
const list = spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
spyOn(sdk.config, "providers").mockImplementation(() =>
Promise.resolve({
data: configured,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: configured.providers,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
},
})
expect(list).not.toHaveBeenCalled()
})
test("falls back to provider list when configured providers are unavailable", async () => {
const sdk = new OpencodeClient()
const data: {
all: Provider[]
default: Record<string, string>
connected: string[]
} = {
all: [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": model("gpt-5", "openai", 128000, {
high: {},
minimal: {},
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "api",
env: [],
options: {},
models: {
sonnet: model("sonnet", "anthropic", 200000),
},
},
],
default: {},
connected: [],
}
spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom"))
spyOn(sdk.provider, "list").mockImplementation(() =>
Promise.resolve({
data,
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}),
)
await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
providers: data.all,
variants: ["high", "minimal"],
limits: {
"openai/gpt-5": 128000,
"anthropic/sonnet": 200000,
},
})
})
})

View File

@@ -0,0 +1,318 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
submit(text: string) {
const next = { text, parts: [] as RunPrompt["parts"] }
for (const fn of [...prompts]) {
fn(next)
}
},
}
}
describe("run runtime queue", () => {
test("ignores empty prompts", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit(" ")
ui.api.close()
await task
expect(calls).toBe(0)
})
test("treats /exit as a close command", async () => {
const ui = footer()
let calls = 0
const task = runPromptQueue({
footer: ui.api,
run: async () => {
calls += 1
},
})
ui.submit("/exit")
await task
expect(calls).toBe(0)
})
test("treats /new as a local session command", async () => {
const ui = footer()
const seen: string[] = []
let created = 0
const task = runPromptQueue({
footer: ui.api,
onNewSession: async () => {
created += 1
},
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
ui.submit("/new")
ui.submit("hello")
await task
expect(created).toBe(1)
expect(seen).toEqual(["hello"])
expect(ui.commits).toEqual([
{
kind: "user",
text: "hello",
phase: "start",
source: "system",
},
])
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
run: async (input) => {
seen.push(input.text)
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
expect(ui.commits).toEqual([
{
kind: "user",
text: " hello ",
phase: "start",
source: "system",
},
])
})
test("passes prompts to onSend", async () => {
const ui = footer()
const seen: string[] = []
await runPromptQueue({
footer: ui.api,
initialInput: " hello ",
onSend: (input) => {
seen.push(input.text)
},
run: async () => {
ui.api.close()
},
})
expect(seen).toEqual([" hello "])
})
test("appends the user row before the turn starts", async () => {
const ui = footer()
await runPromptQueue({
footer: ui.api,
initialInput: "/fmt bash",
run: async () => {
expect(ui.commits).toEqual([
{
kind: "user",
text: "/fmt bash",
phase: "start",
source: "system",
},
])
ui.api.close()
},
})
})
test("runs queued prompts in order", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await task
expect(seen).toEqual(["one", "two"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
seen.push(input.text)
if (seen.length === 1) {
await gate
return
}
ui.api.close()
},
})
ui.submit("one")
await Promise.resolve()
expect(seen).toEqual(["one"])
wake?.()
await Promise.resolve()
ui.submit("two")
await task
expect(seen).toEqual(["one", "two"])
})
test("close aborts the active run and drops pending queued work", async () => {
const ui = footer()
const seen: string[] = []
let hit = false
const task = runPromptQueue({
footer: ui.api,
run: async (input, signal) => {
seen.push(input.text)
await new Promise<void>((resolve) => {
if (signal.aborted) {
hit = true
resolve()
return
}
signal.addEventListener(
"abort",
() => {
hit = true
resolve()
},
{ once: true },
)
})
},
})
ui.submit("one")
await Promise.resolve()
ui.submit("two")
ui.api.close()
await task
expect(hit).toBe(true)
expect(seen).toEqual(["one"])
})
test("propagates run errors", async () => {
const ui = footer()
const task = runPromptQueue({
footer: ui.api,
run: async () => {
throw new Error("boom")
},
})
ui.submit("one")
await expect(task).rejects.toThrow("boom")
})
})

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"
function stream(isTTY: boolean) {
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
}
describe("run interactive stdin", () => {
test("reuses stdin when it is already a tty", () => {
const stdin = stream(true)
const seen: string[] = []
const result = resolveInteractiveStdin(
stdin,
(path) => {
seen.push(path)
return stream(true)
},
"linux",
)
expect(result.stdin).toBe(stdin)
expect(result.cleanup).toBeUndefined()
expect(seen).toEqual([])
})
test("opens the controlling terminal when stdin is piped", () => {
const tty = stream(true)
const seen: string[] = []
const result = resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
return tty
},
"linux",
)
expect(result.stdin).toBe(tty)
expect(seen).toEqual(["/dev/tty"])
result.cleanup?.()
expect(tty.destroyed).toBe(true)
})
test("uses CONIN$ on windows", () => {
const seen: string[] = []
resolveInteractiveStdin(
stream(false),
(path) => {
seen.push(path)
return stream(true)
},
"win32",
)
expect(seen).toEqual(["CONIN$"])
})
test("throws a clear error when no controlling terminal is available", () => {
expect(() =>
resolveInteractiveStdin(
stream(false),
() => {
throw new Error("open failed")
},
"linux",
),
).toThrow(INTERACTIVE_INPUT_ERROR)
})
})

View File

@@ -0,0 +1,883 @@
import { afterEach, expect, test } from "bun:test"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
import type { StreamCommit } from "@/cli/cmd/run/types"
type ClaimedCommit = {
snapshot: {
height: number
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
destroy(): void
}
trailingNewline: boolean
}
const decoder = new TextDecoder()
const active: TestRenderer[] = []
afterEach(() => {
for (const renderer of active.splice(0)) {
renderer.destroy()
}
})
function claim(renderer: TestRenderer): ClaimedCommit[] {
const queue = Reflect.get(renderer, "externalOutputQueue")
if (!queue || typeof queue !== "object" || !("claim" in queue) || typeof queue.claim !== "function") {
throw new Error("renderer missing external output queue")
}
const commits = queue.claim()
if (!Array.isArray(commits)) {
throw new Error("renderer external output queue returned invalid commits")
}
return commits as ClaimedCommit[]
}
function renderCommit(commit: ClaimedCommit) {
return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
}
function render(commits: ClaimedCommit[]) {
return commits.map(renderCommit).join("")
}
function renderRows(commit: ClaimedCommit, width = 80) {
const raw = decoder.decode(commit.snapshot.getRealCharBytes(true))
return Array.from({ length: commit.snapshot.height }, (_, index) =>
raw.slice(index * width, (index + 1) * width).trimEnd(),
)
}
function destroy(commits: ClaimedCommit[]) {
for (const commit of commits) {
commit.snapshot.destroy()
}
}
async function setup(input: {
width?: number
wrote?: boolean
} = {}) {
const out = await createTestRenderer({
width: input.width ?? 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
treeSitterClient.setMockResult({ highlights: [] })
return {
renderer: out.renderer,
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: input.wrote ?? false,
}),
}
}
function assistant(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
return {
kind: "assistant",
text,
phase,
source: "assistant",
messageID: "msg-1",
partID: "part-1",
}
}
function user(text: string): StreamCommit {
return {
kind: "user",
text,
phase: "start",
source: "system",
}
}
function error(text: string): StreamCommit {
return {
kind: "error",
text,
phase: "start",
source: "system",
}
}
function toolPart(tool: string, state: Record<string, unknown>, id: string, messageID: string): ToolPart {
return {
id,
sessionID: "session-1",
messageID,
type: "tool",
callID: `call-${id}`,
tool,
state,
} as ToolPart
}
function toolCommit(input: {
tool: string
phase: StreamCommit["phase"]
toolState?: StreamCommit["toolState"]
text?: string
state?: Record<string, unknown>
id?: string
messageID?: string
}): StreamCommit {
const id = input.id ?? `${input.tool}-1`
const messageID = input.messageID ?? `msg-${input.tool}`
return {
kind: "tool",
text: input.text ?? "",
phase: input.phase,
source: "tool",
partID: id,
messageID,
tool: input.tool,
...(input.toolState ? { toolState: input.toolState } : {}),
...(input.state ? { part: toolPart(input.tool, input.state, id, messageID) } : {}),
}
}
test("finalizes markdown tables for streamed and coalesced input", async () => {
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
for (const chunks of [[text], [...text]]) {
const out = await setup()
try {
for (const chunk of chunks) {
await out.scrollback.append(assistant(chunk))
}
await out.scrollback.complete()
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output).toContain("Column 1")
expect(output).toContain("Row 2")
expect(output).toContain("Value 4")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
}
})
test("holds markdown code blocks until final commit and keeps newline ownership", async () => {
const out = await setup()
try {
await out.scrollback.append(
assistant('# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```'),
)
const progress = claim(out.renderer)
try {
expect(progress).toHaveLength(1)
expect(render(progress)).toContain("Markdown Sample")
expect(render(progress)).toContain("Item 2")
expect(render(progress)).not.toContain("console.log(message)")
} finally {
destroy(progress)
}
await out.scrollback.complete()
const final = claim(out.renderer)
try {
expect(final).toHaveLength(1)
expect(final[0]!.trailingNewline).toBe(false)
expect(render(final)).toContain('const message = "Hello, markdown"')
expect(render(final)).toContain("console.log(message)")
} finally {
destroy(final)
}
} finally {
out.scrollback.destroy()
}
})
test("renders todo and question summaries without boilerplate footer copy", async () => {
const cases = [
{
title: "# Todos",
include: [
"[✓] List files under `run/`",
"[•] Count functions in each `run/` file",
"[ ] Mark each tracking item complete",
],
exclude: ["Updating", "todos completed"],
start: toolCommit({
tool: "todowrite",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "todowrite",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
metadata: {},
time: { start: 1, end: 4 },
},
}),
},
{
title: "# Questions",
include: ["What should I work on in the codebase next?", "Bug fix"],
exclude: ["Asked", "questions completed"],
start: toolCommit({
tool: "question",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
questions: [
{
question: "What should I work on in the codebase next?",
header: "Next work",
options: [{ label: "bug", description: "Bug fix" }],
multiple: false,
},
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "question",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
questions: [
{
question: "What should I work on in the codebase next?",
header: "Next work",
options: [{ label: "bug", description: "Bug fix" }],
multiple: false,
},
],
},
metadata: {
answers: [["Bug fix"]],
},
time: { start: 1, end: 2100 },
},
}),
},
]
for (const item of cases) {
const out = await setup()
try {
await out.scrollback.append(item.start)
expect(claim(out.renderer)).toHaveLength(0)
await out.scrollback.append(item.final)
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
const rows = renderRows(commits[0]!)
const output = rows.join("\n")
expect(output).toContain(item.title)
for (const line of item.include) {
expect(output).toContain(line)
}
for (const line of item.exclude) {
expect(output).not.toContain(line)
}
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
}
})
test("inserts spacers for new visible groups", async () => {
const prior = await setup({ wrote: true })
try {
await prior.scrollback.append(user("use subagent to explore run.ts"))
const commits = claim(prior.renderer)
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).trim()).toBe(" use subagent to explore run.ts")
} finally {
destroy(commits)
}
} finally {
prior.scrollback.destroy()
}
const grouped = await setup()
try {
await grouped.scrollback.append(assistant("hello"))
await grouped.scrollback.complete()
destroy(claim(grouped.renderer))
await grouped.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
text: "running glob",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/run.ts",
},
time: { start: 1 },
},
}),
)
const commits = claim(grouped.renderer)
try {
expect(commits).toHaveLength(2)
expect(renderCommit(commits[0]!).trim()).toBe("")
expect(renderCommit(commits[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
} finally {
destroy(commits)
}
} finally {
grouped.scrollback.destroy()
}
})
test("coalesces same-line tool progress into one snapshot", async () => {
const out = await setup()
try {
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "abc" }))
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "def" }))
await out.scrollback.append(toolCommit({ tool: "bash", phase: "final", text: "", toolState: "completed" }))
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
expect(render(commits)).toContain("abcdef")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders completed bash output with one blank line after the command and before the next group", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(user("/fmt bash"))
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "git status",
workdir: "/tmp/demo",
description: "Show git status",
},
time: { start: 1 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
state: {
status: "completed",
input: {
command: "git status",
workdir: "/tmp/demo",
description: "Show git status",
},
time: { start: 1, end: 2 },
},
}),
)
take()
await out.scrollback.append(assistant("oc-run-dev ahead 1"))
await out.scrollback.complete()
take()
const output = lines.join("\n")
expect(output).toContain("$ git status\n\nOn branch demo")
expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1")
expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1")
} finally {
out.scrollback.destroy()
}
})
test("inserts a spacer before the next tool after completed multiline bash output", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "pwd; ls -la",
workdir: "/tmp/demo",
description: "Lists current directory files",
},
time: { start: 1 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
state: {
status: "completed",
input: {
command: "pwd; ls -la",
workdir: "/tmp/demo",
description: "Lists current directory files",
},
output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"),
title: "pwd; ls -la",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 2 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/*tool*",
path: "src/cli/cmd",
},
time: { start: 3 },
},
}),
)
take()
const output = lines.join("\n")
expect(output).toContain("total 4\n\n✱ Glob \"**/*tool*\" in src/cli/cmd")
} finally {
out.scrollback.destroy()
}
})
test("does not double-space before completed bash output when inline tool headers intervene", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
command: "ls",
workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
},
time: { start: 1 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "glob",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
pattern: "**/*tool*",
path: "src/cli/cmd/run",
},
time: { start: 2 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "grep",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
pattern: "tool",
path: "src/cli/cmd/run",
},
time: { start: 3 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "bash",
phase: "progress",
toolState: "completed",
text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
state: {
status: "completed",
input: {
command: "ls",
workdir: "src/cli/cmd/run",
description: "Lists files in run directory",
},
output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"),
title: "ls",
metadata: {
exitCode: 0,
},
time: { start: 1, end: 4 },
},
}),
)
take()
const output = lines.join("\n")
expect(output).toContain('✱ Grep "tool" in src/cli/cmd/run\n\ndemo.ts')
expect(output).not.toContain('✱ Grep "tool" in src/cli/cmd/run\n\n\ndemo.ts')
} finally {
out.scrollback.destroy()
}
})
test("does not emit blank patch snapshots between edit and task", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = () => {
const commits = claim(out.renderer)
try {
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(
toolCommit({
tool: "edit",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
filePath: "src/demo-format.ts",
},
output: "",
title: "edit",
metadata: {
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
},
time: { start: 1, end: 2 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "apply_patch",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
patchText: "*** Begin Patch\n*** End Patch",
},
output: "",
title: "apply_patch",
metadata: {
files: [
{
type: "update",
filePath: "src/demo-format.ts",
relativePath: "src/demo-format.ts",
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
deletions: 1,
},
{
type: "add",
filePath: "README-demo.md",
relativePath: "README-demo.md",
},
],
},
time: { start: 2, end: 3 },
},
}),
)
take()
await out.scrollback.append(
toolCommit({
tool: "task",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
description: "Scan run/* for reducer touchpoints",
subagent_type: "explore",
},
output: "",
title: "task",
metadata: {
sessionId: "sub_demo_1",
},
time: { start: 3, end: 4 },
},
}),
)
take()
const output = lines.join("\n")
expect(output).toContain("+ Created README-demo.md")
expect(output).not.toContain("~ Patched src/demo-format.ts")
expect(output).toContain("+ Created README-demo.md\n\n# Explore Task")
expect(output).not.toContain("+ Created README-demo.md\n\n\n# Explore Task")
} finally {
out.scrollback.destroy()
}
})
test("renders plain errors with one blank line before and after the error block", async () => {
const out = await setup()
try {
const lines: string[] = []
const take = (check?: (commits: ClaimedCommit[]) => void) => {
const commits = claim(out.renderer)
try {
check?.(commits)
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
} finally {
destroy(commits)
}
}
await out.scrollback.append(user("/fmt error"))
take()
await out.scrollback.append(error("demo error event"))
take((commits) => {
expect(commits.at(-1)?.trailingNewline).toBe(false)
})
await out.scrollback.append(assistant("next line"))
await out.scrollback.complete()
take()
const output = lines.join("\n")
expect(output).toContain(" /fmt error\n\ndemo error event")
expect(output).toContain("demo error event\n\nnext line")
expect(output).not.toContain("demo error event\n\n\nnext line")
} finally {
out.scrollback.destroy()
}
})
test("renders structured write finals once as code blocks", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "write",
phase: "start",
toolState: "running",
id: "tool-2",
messageID: "msg-2",
state: {
status: "running",
input: {
filePath: "src/a.ts",
content: "const x = 1\nconst y = 2\n",
},
time: { start: 1 },
},
}),
)
expect(claim(out.renderer)).toHaveLength(0)
await out.scrollback.append(
toolCommit({
tool: "write",
phase: "final",
toolState: "completed",
id: "tool-2",
messageID: "msg-2",
state: {
status: "completed",
input: {
filePath: "src/a.ts",
content: "const x = 1\nconst y = 2\n",
},
metadata: {},
time: { start: 1, end: 2 },
},
}),
)
const commits = claim(out.renderer)
try {
expect(commits).toHaveLength(1)
const output = render(commits[0] ? [commits[0]] : [])
expect(output).toContain("# Wrote src/a.ts")
expect(output).toMatch(/1\s+const x = 1/)
expect(output).toMatch(/2\s+const y = 2/)
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})
test("renders promoted task markdown without a leading blank row", async () => {
const out = await setup()
try {
await out.scrollback.append(
toolCommit({
tool: "task",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
description: "Explore run.ts",
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"Location: `/tmp/run.ts`",
"",
"Summary:",
"- Local interactive mode",
"- Attach mode",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: { start: 1, end: 2 },
},
}),
)
const commits = claim(out.renderer)
try {
const output = render(commits)
expect(output.startsWith("\n")).toBe(false)
expect(output).toContain("Summary:")
expect(output).toContain("Local interactive mode")
} finally {
destroy(commits)
}
} finally {
out.scrollback.destroy()
}
})

View File

@@ -0,0 +1,422 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
import type { StreamCommit } from "@/cli/cmd/run/types"
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
return reduceSessionData({
data,
event: event as Event,
sessionID: "session-1",
thinking,
limits: {},
})
}
function assistant(id: string, extra: Record<string, unknown> = {}) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "assistant",
providerID: "openai",
modelID: "gpt-5",
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...extra,
},
},
}
}
function user(id: string) {
return {
type: "message.updated",
properties: {
sessionID: "session-1",
info: {
id,
role: "user",
},
},
}
}
function text(input: {
id: string
messageID: string
text: string
time?: Record<string, number>
}) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "text",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "reasoning",
text: input.text,
...(input.time ? { time: input.time } : {}),
},
},
}
}
function delta(messageID: string, partID: string, value: string) {
return {
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID,
partID,
field: "text",
delta: value,
},
}
}
function tool(input: {
id: string
messageID: string
tool: string
state: Record<string, unknown>
callID?: string
}) {
return {
type: "message.part.updated",
properties: {
part: {
id: input.id,
messageID: input.messageID,
sessionID: "session-1",
type: "tool",
tool: input.tool,
...(input.callID ? { callID: input.callID } : {}),
state: input.state,
},
},
}
}
describe("run session data", () => {
test("buffers delayed assistant text until the role is known", () => {
let data = createSessionData()
data = reduce(data, delta("msg-1", "txt-1", "hello")).data
data = reduce(data, assistant("msg-1")).data
const out = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "",
time: { end: 1 },
}),
)
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "hello",
partID: "txt-1",
}),
])
})
test("keeps leading whitespace buffered until real assistant content arrives", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
let out = reduce(data, delta("msg-1", "txt-1", " "))
expect(out.commits).toEqual([])
out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
expect(out.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: " Found",
}),
])
})
test("drops delayed text once the message resolves to a user role", () => {
let data = createSessionData()
data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
const out = reduce(data, user("msg-user-1"))
expect(out.commits).toEqual([])
expect(out.data.ids.has("txt-user-1")).toBe(true)
})
test("suppresses reasoning commits when thinking is disabled", () => {
const out = reduce(
createSessionData(),
reasoning({
id: "reason-1",
messageID: "msg-1",
text: "hidden",
time: { end: 1 },
}),
false,
)
expect(out.commits).toEqual([])
expect(out.data.ids.has("reason-1")).toBe(true)
})
test("keeps permission precedence over queued questions", () => {
let data = createSessionData()
data = reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "read",
patterns: ["/tmp/file.txt"],
metadata: {},
always: [],
},
}).data
const ask = reduce(data, {
type: "question.asked",
properties: {
id: "question-1",
sessionID: "session-1",
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "chunked", description: "Incremental output" }],
multiple: false,
},
],
},
})
expect(ask.footer).toEqual({
patch: { status: "awaiting permission" },
view: {
type: "permission",
request: expect.objectContaining({ id: "perm-1" }),
},
})
expect(
reduce(ask.data, {
type: "permission.replied",
properties: {
sessionID: "session-1",
requestID: "perm-1",
reply: "reject",
},
}).footer,
).toEqual({
patch: { status: "awaiting answer" },
view: {
type: "question",
request: expect.objectContaining({ id: "question-1" }),
},
})
})
test("refreshes the active permission view when tool input arrives later", () => {
const data = reduce(createSessionData(), {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "bash",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
tool: {
messageID: "msg-1",
callID: "call-1",
},
},
}).data
const out = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
},
}),
)
expect(out.footer).toEqual({
view: {
type: "permission",
request: expect.objectContaining({
id: "perm-1",
metadata: expect.objectContaining({
input: {
command: "git status --short",
},
}),
}),
},
})
})
test("strips bash echo only from the first assistant flush", () => {
let data = createSessionData()
data = reduce(data, assistant("msg-1")).data
data = reduce(
data,
tool({
id: "tool-1",
messageID: "msg-1",
tool: "bash",
state: {
status: "completed",
input: {
command: "printf hi",
},
output: "echoed\n",
time: { start: 1, end: 2 },
},
}),
).data
const first = reduce(
data,
text({
id: "txt-1",
messageID: "msg-1",
text: "echoed\nanswer",
}),
)
expect(first.commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "answer",
}),
])
expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
expect.objectContaining({
kind: "assistant",
text: "\nechoed\nagain",
}),
])
})
test("synthesizes a glob start before an error when the running update is missed", () => {
expect(
reduce(
createSessionData(),
tool({
id: "tool-1",
messageID: "msg-1",
tool: "glob",
state: {
status: "error",
input: {
pattern: "**/*tool*",
path: "/tmp/demo/run",
},
error: "No such file or directory: '/tmp/demo/run'",
},
}),
).commits,
).toEqual([
expect.objectContaining({
kind: "tool",
tool: "glob",
phase: "start",
partID: "tool-1",
text: "running glob",
toolState: "running",
}),
expect.objectContaining({
kind: "tool",
tool: "glob",
phase: "final",
partID: "tool-1",
text: "No such file or directory: '/tmp/demo/run'",
toolState: "error",
toolError: "No such file or directory: '/tmp/demo/run'",
}),
])
})
test("flushInterrupted emits one interrupted final per live part", () => {
const data = reduce(
createSessionData(),
text({
id: "txt-1",
messageID: "msg-1",
text: "unfinished",
}),
).data
const first: StreamCommit[] = []
flushInterrupted(data, first)
expect(first).toEqual([
expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
])
const next: StreamCommit[] = []
flushInterrupted(data, next)
expect(next).toEqual([])
})
test("surfaces session errors as error commits", () => {
const out = reduce(createSessionData(), {
type: "session.error",
properties: {
sessionID: "session-1",
error: {
name: "UnknownError",
data: {
message: "permission denied",
},
},
},
})
expect(out.commits).toEqual([
expect.objectContaining({
kind: "error",
text: "permission denied",
}),
])
})
})

View File

@@ -0,0 +1,247 @@
import { describe, expect, test } from "bun:test"
import {
createSession,
sessionHistory,
sessionVariant,
type RunSession,
type SessionMessages,
} from "@/cli/cmd/run/session.shared"
type Message = SessionMessages[number]
type Part = Message["parts"][number]
type TextPart = Extract<Part, { type: "text" }>
type AgentPart = Extract<Part, { type: "agent" }>
type FilePart = Extract<Part, { type: "file" }>
const model = {
providerID: "openai",
modelID: "gpt-5",
}
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: {
...model,
variant,
},
},
parts,
}
}
function assistantMessage(id: string, parts: Message["parts"]): Message {
return {
info: {
id,
sessionID: "session-1",
role: "assistant",
time: {
created: 1,
},
parentID: "msg-user-1",
modelID: "gpt-5",
providerID: "openai",
mode: "chat",
agent: "build",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 1,
output: 1,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
},
parts,
}
}
function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
return {
id,
sessionID: "session-1",
messageID,
type: "text",
text,
synthetic: input.synthetic,
}
}
function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
return {
id,
sessionID: "session-1",
messageID,
type: "agent",
name,
source,
}
}
function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
return {
id,
sessionID: "session-1",
messageID,
type: "file",
mime: input.mime ?? "text/plain",
filename: input.filename,
url,
source: input.source,
}
}
describe("run session shared", () => {
test("builds user prompt text from text, file, and agent parts", () => {
const msgs: SessionMessages = [
assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan"),
textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
agentPart("agent-user-1", "msg-user-1", "scan", {
start: 5,
end: 10,
value: "@scan",
}),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
]
const out = createSession(msgs)
expect(out.first).toBe(false)
expect(out.turns).toHaveLength(1)
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
expect(out.turns[0]?.prompt.parts).toEqual([
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
])
})
test("reuses existing mentions when file and agent parts have no source", () => {
const out = createSession([
userMessage("msg-user-1", [
textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
agentPart("agent-user-1", "msg-user-1", "scan"),
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
]),
])
expect(out.turns[0]?.prompt).toEqual({
text: "look @scan @note.ts",
parts: [
{
type: "agent",
name: "scan",
source: {
start: 5,
end: 10,
value: "@scan",
},
},
{
type: "file",
mime: "text/plain",
filename: undefined,
url: "file:///tmp/note.ts",
source: {
type: "file",
path: "file:///tmp/note.ts",
text: {
start: 11,
end: 19,
value: "@note.ts",
},
},
},
],
})
})
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
const parts = [
{
type: "agent" as const,
name: "scan",
source: {
start: 0,
end: 5,
value: "@scan",
},
},
]
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
const out = sessionHistory(session)
expect(out.map((item) => item.text)).toEqual(["one", "two"])
expect(out[0]?.parts).toEqual(parts)
expect(out[0]?.parts).not.toBe(parts)
expect(out[0]?.parts[0]).not.toBe(parts[0])
})
test("returns the latest matching variant for the active model", () => {
const session: RunSession = {
first: false,
turns: [
{ prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
{ prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
{ prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
],
}
expect(sessionVariant(session, model)).toBeUndefined()
session.turns.push({
prompt: { text: "four", parts: [] },
provider: "openai",
model: "gpt-5",
variant: "minimal",
})
expect(sessionVariant(session, model)).toBe("minimal")
})
})

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "@/cli/cmd/run/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
function footer() {
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
},
append: (next) => {
commits.push(next)
},
idle: () => Promise.resolve(),
close: () => {},
destroy: () => {},
}
return { api, events, commits }
}
describe("run stream bridge", () => {
test("defaults status patches to running phase", () => {
const out = footer()
writeSessionOutput(
{
footer: out.api,
},
{
commits: [],
footer: {
patch: {
status: "assistant responding",
},
},
},
)
expect(out.events).toEqual([
{
type: "stream.patch",
patch: {
phase: "running",
status: "assistant responding",
},
},
])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
import { describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2"
import { entryBody } from "@/cli/cmd/run/entry.body"
import {
bootstrapSubagentData,
clearFinishedSubagents,
createSubagentData,
reduceSubagentData,
snapshotSubagentData,
} from "@/cli/cmd/run/subagent-data"
type SessionMessage = Parameters<typeof bootstrapSubagentData>[0]["messages"][number]
function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
return commits.flatMap((item) => {
const body = entryBody(item)
if (body.type === "none") {
return []
}
if (body.type === "structured") {
if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
return [body.snapshot.title]
}
if (body.snapshot.kind === "diff") {
return body.snapshot.items.map((item) => item.title)
}
if (body.snapshot.kind === "todo") {
return ["# Todos"]
}
return ["# Questions"]
}
return [body.content]
})
}
function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
return reduceSubagentData({
data,
event: event as Event,
sessionID: "parent-1",
thinking: true,
limits: {},
})
}
function taskMessage(sessionID: string, status: "running" | "completed" = "completed"): SessionMessage {
if (status === "running") {
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "running",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1 },
},
},
],
}
}
return {
parts: [
{
id: `part-${sessionID}`,
sessionID: "parent-1",
messageID: `msg-${sessionID}`,
type: "tool",
callID: `call-${sessionID}`,
tool: "task",
state: {
status: "completed",
input: {
description: "Scan reducer paths",
subagent_type: "explore",
},
output: "",
title: "Reducer touchpoints",
metadata: {
sessionId: sessionID,
toolcalls: 4,
},
time: { start: 1, end: 2 },
},
},
],
}
}
function question(id: string, sessionID: string) {
return {
id,
sessionID,
questions: [
{
question: "Mode?",
header: "Mode",
options: [{ label: "Fast", description: "Quick pass" }],
multiple: false,
},
],
}
}
describe("run subagent data", () => {
test("bootstraps tabs and child blockers from parent task parts", () => {
const data = createSubagentData()
expect(
bootstrapSubagentData({
data,
messages: [taskMessage("child-1")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [
{
id: "perm-1",
sessionID: "child-1",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
{
id: "perm-2",
sessionID: "other",
permission: "read",
patterns: ["src/**/*.ts"],
metadata: {},
always: [],
},
],
questions: [question("question-1", "child-1"), question("question-2", "other")],
}),
).toBe(true)
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Scan reducer paths",
title: "Reducer touchpoints",
status: "completed",
toolCalls: 4,
}),
])
expect(snapshot.details).toEqual({
"child-1": {
sessionID: "child-1",
commits: [],
},
})
expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"])
expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
})
test("captures child activity and blocker metadata in the footer detail state", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "running")],
children: [{ id: "child-1" }],
permissions: [],
questions: [],
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-user-1",
messageID: "msg-user-1",
sessionID: "child-1",
type: "text",
text: "Inspect footer tabs",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-user-1",
role: "user",
},
},
})
reduce(data, {
type: "message.updated",
properties: {
sessionID: "child-1",
info: {
id: "msg-assistant-1",
role: "assistant",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "reason-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "reasoning",
text: "planning next steps",
time: { start: 1 },
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "tool",
callID: "call-1",
tool: "bash",
state: {
status: "running",
input: {
command: "git status --short",
},
time: { start: 1 },
},
},
},
})
reduce(data, {
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "child-1",
permission: "bash",
patterns: ["git status --short"],
metadata: {},
always: [],
tool: {
messageID: "msg-assistant-1",
callID: "call-1",
},
},
})
reduce(data, {
type: "message.part.updated",
properties: {
part: {
id: "txt-1",
messageID: "msg-assistant-1",
sessionID: "child-1",
type: "text",
text: "hello",
},
},
})
reduce(data, {
type: "message.part.delta",
properties: {
sessionID: "child-1",
messageID: "msg-assistant-1",
partID: "txt-1",
field: "text",
delta: " world",
},
})
const snapshot = snapshotSubagentData(data)
expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
" Inspect footer tabs",
"_Thinking:_ planning next steps",
"# Shell\n$ git status --short",
"hello world",
])
expect(snapshot.permissions).toEqual([
expect.objectContaining({
id: "perm-1",
metadata: {
input: {
command: "git status --short",
},
},
}),
])
expect(snapshot.questions).toEqual([])
})
test("clears finished tabs on the next parent prompt", () => {
const data = createSubagentData()
bootstrapSubagentData({
data,
messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
children: [{ id: "child-1" }, { id: "child-2" }],
permissions: [],
questions: [],
})
expect(clearFinishedSubagents(data)).toBe(true)
expect(snapshotSubagentData(data).tabs).toEqual([
expect.objectContaining({ sessionID: "child-2", status: "running" }),
])
})
})

View File

@@ -0,0 +1,122 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
const palette = [
"#15161e",
"#f7768e",
"#9ece6a",
"#e0af68",
"#7aa2f7",
"#bb9af7",
"#7dcfff",
"#c0caf5",
] as const
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
defaultBackground: input.defaultBackground ?? "#1a1b26",
defaultForeground: input.defaultForeground ?? "#c0caf5",
cursorColor: input.cursorColor ?? "#ff9e64",
mouseForeground: input.mouseForeground ?? null,
mouseBackground: input.mouseBackground ?? null,
tekForeground: input.tekForeground ?? null,
tekBackground: input.tekBackground ?? null,
highlightBackground: input.highlightBackground ?? "#33467c",
highlightForeground: input.highlightForeground ?? "#c0caf5",
}
}
function renderer(input: {
themeMode?: "dark" | "light"
colors?: TerminalColors
fail?: boolean
} = {}) {
return {
themeMode: input.themeMode,
getPalette: async () => {
if (input.fail) {
throw new Error("boom")
}
return input.colors ?? terminalColors()
},
} as CliRenderer
}
function expectRgba(color: unknown) {
expect(color).toBeInstanceOf(RGBA)
if (!(color instanceof RGBA)) {
throw new Error("expected RGBA")
}
return color
}
function expectIndexed(color: unknown) {
const rgba = expectRgba(color)
expect(rgba.intent).toBe("indexed")
expect(rgba.slot).toBeLessThan(256)
}
function spread(color: RGBA) {
const [r, g, b] = color.toInts()
return Math.max(r, g, b) - Math.min(r, g, b)
}
test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
})
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
try {
expect(theme.block.syntax).toBeDefined()
expect(theme.block.subtleSyntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
expectIndexed(theme.splash.rightShadow)
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.surface)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
test("keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#0000ff",
defaultForeground: "#ffffff",
}),
"dark",
),
"dark",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(10)
expect(spread(theme.backgroundElement)).toBeLessThan(10)
})
test("keeps light surfaces close to neutral on warm backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
}),
"light",
),
"light",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(60)
expect(spread(theme.backgroundElement)).toBeLessThan(60)
})

View File

@@ -0,0 +1,214 @@
import path from "path"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { describe, expect, test } from "bun:test"
import { Effect, FileSystem, Layer } from "effect"
import { Global } from "@opencode-ai/core/global"
import {
createVariantRuntime,
cycleVariant,
formatModelLabel,
pickVariant,
resolveVariant,
} from "@/cli/cmd/run/variant.shared"
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
import type { RunProvider } from "@/cli/cmd/run/types"
import { testEffect } from "../../lib/effect"
const model = {
providerID: "openai",
modelID: "gpt-5",
}
const providers: RunProvider[] = [
{
id: "openai",
name: "OpenAI",
source: "api",
env: [],
options: {},
models: {
"gpt-5": {
id: "gpt-5",
providerID: "openai",
api: {
id: "gpt-5",
url: "https://openai.test",
npm: "@ai-sdk/openai",
},
name: "GPT-5",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
},
},
]
function userMessage(id: string, input: { providerID: string; modelID: string; variant?: string }): SessionMessages[number] {
return {
info: {
id,
sessionID: "session-1",
role: "user",
time: {
created: 1,
},
agent: "build",
model: input,
},
parts: [],
}
}
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, NodeFileSystem.layer))
function remap(root: string, file: string) {
if (file === Global.Path.state) {
return root
}
if (file.startsWith(Global.Path.state + path.sep)) {
return path.join(root, path.relative(Global.Path.state, file))
}
return file
}
function remappedFs(root: string) {
return Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return AppFileSystem.Service.of({
...fs,
readJson: (file) => fs.readJson(remap(root, file)),
writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode),
})
}),
).pipe(Layer.provide(AppFileSystem.defaultLayer))
}
describe("run variant shared", () => {
test("prefers cli then session then saved variants", () => {
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
})
test("cycles through variants and back to default", () => {
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
expect(cycleVariant("low", ["low", "high"])).toBe("high")
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
expect(cycleVariant(undefined, [])).toBeUndefined()
})
test("formats model labels", () => {
expect(formatModelLabel(model, undefined)).toBe("gpt-5 · openai")
expect(formatModelLabel(model, "high")).toBe("gpt-5 · openai · high")
expect(formatModelLabel(model, undefined, providers)).toBe("GPT-5 · OpenAI")
expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high")
})
test("picks the latest matching variant from raw session messages", () => {
const msgs: SessionMessages = [
userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }),
userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }),
userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }),
]
expect(pickVariant(model, msgs)).toBe("minimal")
})
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* fs.writeJson(file, {
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
"openai/gpt-5": "high",
},
})
yield* Effect.promise(() => svc.saveVariant(model, undefined))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBeUndefined()
expect(yield* fs.readJson(file)).toEqual({
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
variant: {
"openai/gpt-4.1": "low",
},
})
}),
)
it.live("repairs malformed saved variant state on the next write", () =>
Effect.gen(function* () {
const filesys = yield* FileSystem.FileSystem
const fs = yield* AppFileSystem.Service
const root = yield* filesys.makeTempDirectoryScoped()
const file = path.join(root, "model.json")
yield* filesys.writeFileString(file, "{")
const svc = createVariantRuntime(remappedFs(root))
yield* Effect.promise(() => svc.saveVariant(model, "high"))
expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high")
expect(yield* fs.readJson(file)).toEqual({
variant: {
"openai/gpt-5": "high",
},
})
}),
)
})