run: add -i interactive minimal mode

This commit is contained in:
Simon Klee
2026-04-24 14:07:31 +02:00
parent dcee1c3642
commit 6da2e6fe50
51 changed files with 18062 additions and 337 deletions
@@ -0,0 +1,500 @@
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
}
type QueueRenderer = {
externalOutputQueue: {
claim(): ClaimedCommit[]
}
}
const decoder = new TextDecoder()
const active: TestRenderer[] = []
afterEach(() => {
for (const renderer of active.splice(0)) {
renderer.destroy()
}
})
function claim(renderer: TestRenderer) {
return (renderer as unknown as QueueRenderer).externalOutputQueue.claim()
}
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 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 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()
}
})