diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts index 48041edcdb..93ed7c1985 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -249,8 +249,15 @@ export class RunScrollbackStream { this.renderer.writeToScrollback(spacerWriter()) } - if (body.type !== "structured" && entryCanStream(commit, body)) { + if ( + body.type !== "structured" && + (entryCanStream(commit, body) || + (commit.kind === "tool" && commit.phase === "final" && body.type === "markdown")) + ) { await this.writeStreaming(commit, body) + if (entryDone(commit)) { + await this.finishActive(entryFlags(commit).trailingNewline) + } this.wrote = true this.tail = commit return diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 28e64d5330..70d14b9955 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -2,6 +2,7 @@ import { createScrollbackWriter } from "@opentui/solid" import { TextRenderable, type ScrollbackWriter } from "@opentui/core" +import { createMemo } from "solid-js" import { entryBody, entryFlags } from "./entry.body" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool" @@ -57,53 +58,53 @@ export function RunEntryContent(props: { width?: number }) { const theme = props.theme ?? RUN_THEME_FALLBACK - const body = entryBody(props.commit) - if (body.type === "none") { + const body = createMemo(() => entryBody(props.commit)) + if (body().type === "none") { return null } - if (body.type === "text") { + if (body().type === "text") { const style = entryLook(props.commit, theme.entry) return ( - {body.content} + {body().content} ) } - if (body.type === "code") { + if (body().type === "code") { return ( ) } - if (body.type === "structured") { + if (body().type === "structured") { const width = Math.max(1, Math.trunc(props.width ?? 80)) - if (body.snapshot.kind === "code") { + if (body().snapshot.kind === "code") { return ( - {body.snapshot.title} + {body().snapshot.title} @@ -112,11 +113,11 @@ export function RunEntryContent(props: { ) } - if (body.snapshot.kind === "diff") { + if (body().snapshot.kind === "diff") { const view = toolDiffView(width, props.opts?.diffStyle) return ( - {body.snapshot.items.map((item) => ( + {body().snapshot.items.map((item) => ( {item.title} @@ -154,21 +155,21 @@ export function RunEntryContent(props: { ) } - if (body.snapshot.kind === "task") { + if (body().snapshot.kind === "task") { return ( - {body.snapshot.title} + {body().snapshot.title} - {body.snapshot.rows.map((row) => ( + {body().snapshot.rows.map((row) => ( {row} ))} - {body.snapshot.tail ? ( + {body().snapshot.tail ? ( - {body.snapshot.tail} + {body().snapshot.tail} ) : null} @@ -176,21 +177,21 @@ export function RunEntryContent(props: { ) } - if (body.snapshot.kind === "todo") { + if (body().snapshot.kind === "todo") { return ( # Todos - {body.snapshot.items.map((item) => ( + {body().snapshot.items.map((item) => ( {todoText(item)} ))} - {body.snapshot.tail ? ( + {body().snapshot.tail ? ( - {body.snapshot.tail} + {body().snapshot.tail} ) : null} @@ -199,27 +200,27 @@ export function RunEntryContent(props: { } return ( - - - # Questions - - - {body.snapshot.items.map((item) => ( - - - {item.question} + + + # Questions + + + {body().snapshot.items.map((item) => ( + + + {item.question} {item.answer} - - ))} - {body.snapshot.tail ? ( - - {body.snapshot.tail} - - ) : null} - + + ))} + {body().snapshot.tail ? ( + + {body().snapshot.tail} + + ) : null} + ) } @@ -229,7 +230,7 @@ export function RunEntryContent(props: { width="100%" syntaxStyle={entrySyntax(props.commit, theme)} streaming={props.commit.phase === "progress"} - content={body.content} + content={body().content} fg={entryColor(props.commit, theme)} tableOptions={{ widthMode: "content" }} /> diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 3321eb4a2b..4582e5bf7b 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -771,14 +771,26 @@ function scrollPatchFinal(p: ToolProps): string { return rows.join("\n") } -function scrollTaskStart(p: ToolProps): string { - const kind = Locale.titlecase(p.input.subagent_type || "general") - const desc = p.input.description - if (!desc) { - return `│ ${kind} Task` +function scrollTaskStart(_: ToolProps): string { + return "" +} + +function taskResult(output: string) { + if (!output.trim()) { + return } - return `│ ${kind} Task — ${desc}` + const match = output.match(/\s*([\s\S]*?)\s*<\/task_result>/) + if (match) { + return match[1].trim() || undefined + } + + const next = output + .split("\n") + .filter((line) => !line.startsWith("task_id:")) + .join("\n") + .trim() + return next || undefined } function scrollTaskFinal(p: ToolProps): string { @@ -1412,6 +1424,17 @@ function textBody(content: string): RunEntryBody | undefined { } } +function markdownBody(content: string): RunEntryBody | undefined { + if (!content) { + return + } + + return { + type: "markdown", + content, + } +} + function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undefined { const snap = toolSnapshot(commit, raw) if (!snap) { @@ -1428,6 +1451,19 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | const ctx = toolFrame(commit, raw) const view = toolView(ctx.name) + if (ctx.name === "task") { + if (commit.phase === "start") { + return + } + + if (commit.phase === "final" && ctx.status === "completed") { + const result = taskResult(text(ctx.state.output)) + if (result) { + return markdownBody(result) + } + } + } + if (commit.phase === "progress" && !view.output) { return } diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts index be6df3ad6e..e55aed38be 100644 --- a/packages/opencode/test/cli/run/entry.body.test.ts +++ b/packages/opencode/test/cli/run/entry.body.test.ts @@ -114,6 +114,139 @@ describe("run entry body", () => { )).toBe(true) }) + test("keeps running task tool state out of scrollback", () => { + expect( + entryBody( + commit({ + kind: "tool", + text: "running inspect reducer", + phase: "start", + source: "tool", + tool: "task", + toolState: "running", + part: { + id: "task-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "running", + input: { + description: "Inspect reducer", + subagent_type: "explore", + }, + }, + } as never, + }), + ), + ).toEqual({ + type: "none", + }) + }) + + test("renders completed task tool finals from promoted task results", () => { + expect( + entryBody( + commit({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + tool: "task", + toolState: "completed", + part: { + id: "task-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "completed", + input: { + description: "Inspect reducer", + subagent_type: "explore", + }, + output: [ + "task_id: child-1 (for resuming to continue this task if needed)", + "", + "", + "# Findings\n\n- Footer stays live", + "", + ].join("\n"), + metadata: { + sessionId: "child-1", + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }), + ), + ).toEqual({ + type: "markdown", + content: "# Findings\n\n- Footer stays live", + }) + }) + + test("falls back to structured task final when task result is empty", () => { + const body = entryBody( + commit({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + tool: "task", + toolState: "completed", + part: { + id: "task-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "completed", + input: { + description: "Inspect reducer", + subagent_type: "explore", + }, + output: [ + "task_id: child-1 (for resuming to continue this task if needed)", + "", + "", + "", + "", + ].join("\n"), + metadata: { + sessionId: "child-1", + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }), + ) + + expect(body.type).toBe("structured") + if (body.type !== "structured") { + throw new Error("expected structured body") + } + + expect(body.snapshot).toEqual({ + kind: "task", + title: "# Explore Task", + rows: ["◉ Inspect reducer", "↳ session child-1"], + tail: "└ Explore task completed · 1ms", + }) + }) + test("streams tool progress text", () => { const body = entryBody( commit({ diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 76f83f86c1..c9ee24732e 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -1,6 +1,53 @@ +/** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { RunEntryContent } from "../../../src/cli/cmd/run/scrollback.writer" import { RunFooterView } from "../../../src/cli/cmd/run/footer.view" +import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme" +import type { StreamCommit } from "../../../src/cli/cmd/run/types" test("run footer view loads", () => { expect(typeof RunFooterView).toBe("function") }) + +test("run entry content updates when live commit text changes", async () => { + const [commit, setCommit] = createSignal({ + kind: "tool", + text: "I", + phase: "progress", + source: "tool", + messageID: "msg-1", + partID: "part-1", + tool: "bash", + }) + + const app = await testRender(() => ( + + + + ), { + 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() + } +}) diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 1abd4f73f2..62525ea71d 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -277,3 +277,78 @@ test("renders structured write finals as native code blocks", async () => { destroyCommits(commits) } }) + +test("renders promoted task-result markdown without leading blank rows", async () => { + const out = await createTestRenderer({ + 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: [] }) + + const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, { + treeSitterClient, + wrote: false, + }) + + await scrollback.append({ + kind: "tool", + text: "", + phase: "final", + source: "tool", + partID: "task-1", + messageID: "msg-1", + tool: "task", + toolState: "completed", + part: { + id: "task-1", + sessionID: "session-1", + messageID: "msg-1", + type: "tool", + callID: "call-1", + tool: "task", + state: { + status: "completed", + input: { + description: "Explore run.ts", + subagent_type: "explore", + }, + output: [ + "task_id: child-1 (for resuming to continue this task if needed)", + "", + "", + "Location: `/tmp/run.ts`", + "", + "Summary:", + "- Local interactive mode", + "- Attach mode", + "", + ].join("\n"), + metadata: { + sessionId: "child-1", + }, + time: { + start: 1, + end: 2, + }, + }, + } as never, + }) + + const commits = claimCommits(out.renderer) + try { + expect(commits.length).toBeGreaterThan(0) + const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("") + expect(rendered.startsWith("\n")).toBe(false) + expect(rendered.split("\n")[0]?.trim()).toBe("Location: `/tmp/run.ts`") + expect(rendered).toContain("Summary:") + expect(rendered).toContain("Local interactive mode") + } finally { + destroyCommits(commits) + } +})