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)
+ }
+})