diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts
index 46f5d1f010..6305c413f6 100644
--- a/packages/opencode/src/cli/cmd/run/demo.ts
+++ b/packages/opencode/src/cli/cmd/run/demo.ts
@@ -27,7 +27,21 @@ import type {
StreamCommit,
} from "./types"
-const KINDS = ["markdown", "table", "text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"]
+const KINDS = [
+ "markdown",
+ "table",
+ "text",
+ "reasoning",
+ "bash",
+ "write",
+ "edit",
+ "patch",
+ "task",
+ "todo",
+ "question",
+ "error",
+ "mix",
+]
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts
new file mode 100644
index 0000000000..1bc4dc6215
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/run/entry.body.ts
@@ -0,0 +1,183 @@
+import { toolEntryBody } from "./tool"
+import type { RunEntryBody, StreamCommit } from "./types"
+
+export type EntryFlags = {
+ startOnNewLine: boolean
+ trailingNewline: boolean
+}
+
+export const RUN_ENTRY_NONE: RunEntryBody = {
+ type: "none",
+}
+
+export function cleanRunText(text: string): string {
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+}
+
+function textBody(content: string): RunEntryBody {
+ if (!content) {
+ return RUN_ENTRY_NONE
+ }
+
+ return {
+ type: "text",
+ content,
+ }
+}
+
+function codeBody(content: string, filetype?: string): RunEntryBody {
+ if (!content) {
+ return RUN_ENTRY_NONE
+ }
+
+ return {
+ type: "code",
+ content,
+ filetype,
+ }
+}
+
+function markdownBody(content: string): RunEntryBody {
+ if (!content) {
+ return RUN_ENTRY_NONE
+ }
+
+ return {
+ type: "markdown",
+ content,
+ }
+}
+
+function userBody(raw: string): RunEntryBody {
+ if (!raw.trim()) {
+ return RUN_ENTRY_NONE
+ }
+
+ const lead = raw.match(/^\n+/)?.[0] ?? ""
+ const body = lead ? raw.slice(lead.length) : raw
+ return textBody(`${lead}› ${body}`)
+}
+
+function reasoningBody(raw: string): RunEntryBody {
+ const clean = raw.replace(/\[REDACTED\]/g, "")
+ if (!clean) {
+ return RUN_ENTRY_NONE
+ }
+
+ const lead = clean.match(/^\n+/)?.[0] ?? ""
+ const body = lead ? clean.slice(lead.length) : clean
+ const mark = "Thinking: "
+ if (body.startsWith(mark)) {
+ return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length)}`, "markdown")
+ }
+
+ return codeBody(clean, "markdown")
+}
+
+function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
+ return textBody(phase === "progress" ? raw : raw.trim())
+}
+
+export function entryFlags(commit: StreamCommit): EntryFlags {
+ if (commit.kind === "user") {
+ return {
+ startOnNewLine: true,
+ trailingNewline: false,
+ }
+ }
+
+ if (commit.kind === "tool") {
+ if (commit.phase === "progress") {
+ return {
+ startOnNewLine: false,
+ trailingNewline: false,
+ }
+ }
+
+ return {
+ startOnNewLine: true,
+ trailingNewline: true,
+ }
+ }
+
+ if (commit.kind === "assistant" || commit.kind === "reasoning") {
+ if (commit.phase === "progress") {
+ return {
+ startOnNewLine: false,
+ trailingNewline: false,
+ }
+ }
+
+ return {
+ startOnNewLine: true,
+ trailingNewline: true,
+ }
+ }
+
+ return {
+ startOnNewLine: true,
+ trailingNewline: true,
+ }
+}
+
+export function entryDone(commit: StreamCommit): boolean {
+ if (commit.kind === "assistant" || commit.kind === "reasoning") {
+ return commit.phase === "final"
+ }
+
+ if (commit.kind === "tool") {
+ return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed")
+ }
+
+ return true
+}
+
+export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean {
+ if (commit.phase !== "progress") {
+ return false
+ }
+
+ if (body.type === "none") {
+ return false
+ }
+
+ return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool"
+}
+
+export function entryBody(commit: StreamCommit): RunEntryBody {
+ const raw = cleanRunText(commit.text)
+
+ if (commit.kind === "user") {
+ return userBody(raw)
+ }
+
+ if (commit.kind === "tool") {
+ return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
+ }
+
+ if (commit.kind === "assistant") {
+ if (commit.phase === "start") {
+ return RUN_ENTRY_NONE
+ }
+
+ if (commit.phase === "final") {
+ return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
+ }
+
+ return markdownBody(raw)
+ }
+
+ if (commit.kind === "reasoning") {
+ if (commit.phase === "start") {
+ return RUN_ENTRY_NONE
+ }
+
+ if (commit.phase === "final") {
+ return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
+ }
+
+ return reasoningBody(raw)
+ }
+
+ return systemBody(raw, commit.phase)
+}
diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/opencode/src/cli/cmd/run/footer.question.tsx
index 6f18181153..6173ccba9e 100644
--- a/packages/opencode/src/cli/cmd/run/footer.question.tsx
+++ b/packages/opencode/src/cli/cmd/run/footer.question.tsx
@@ -292,12 +292,7 @@ export function RunQuestionBody(props: {
backgroundColor={props.theme.surface}
>
-
-
-
- )
-}
-
-function TaskTool(props: { theme: RunTheme; data: TaskInput }) {
- return (
-
-
- {(line) => {line}}
-
-
- {props.data.tail}
-
-
- )
-}
-
-function todoMark(status: string): string {
- if (status === "completed") {
- return "[x]"
+function todoText(item: { status: string; content: string }): string {
+ if (item.status === "completed") {
+ return `[x] ${item.content}`
}
- if (status === "in_progress") {
- return "[>]"
+
+ if (item.status === "cancelled") {
+ return `[ ] ${item.content} (cancelled)`
}
- if (status === "cancelled") {
- return "[-]"
+
+ if (item.status === "in_progress") {
+ return `[ ] ${item.content} (in progress)`
}
- return "[ ]"
-}
-function TodoTool(props: { theme: RunTheme; data: TodoInput }) {
- return (
-
-
-
- {(item) => (
-
- {todoMark(item.status)} {item.content}
-
- )}
-
-
-
- {props.data.tail}
-
-
- )
-}
-
-function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
- return (
-
-
- {props.data.tail}
-
-
-
- {(item) => (
-
- {item.question}
- {item.answer}
-
- )}
-
-
-
- )
-}
-
-function snapCommit(commit: StreamCommit) {
- const state = commit.toolState ?? commit.part?.state.status
- return (
- commit.kind === "tool" &&
- commit.phase === "final" &&
- state === "completed" &&
- Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
- )
+ return `[ ] ${item.content}`
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
@@ -441,7 +139,7 @@ export function entryGroupKey(commit: StreamCommit): string | undefined {
return
}
- if (snapCommit(commit)) {
+ if (toolStructuredFinal(commit)) {
return `tool:${commit.partID}:final`
}
@@ -462,111 +160,6 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start"
}
-export function RunEntryTextContent(props: { commit: StreamCommit; theme: RunEntryTheme }) {
- const body = normalizeEntry(props.commit)
- if (props.commit.kind === "reasoning") {
- return
- }
-
- const style = look(props.commit, props.theme)
- return
-}
-
-export function RunEntrySnapContent(props: {
- commit: StreamCommit
- theme: RunTheme
- opts?: ScrollbackOptions
- width?: number
-}) {
- const raw = clean(props.commit.text)
- const snap = toolSnapshot(props.commit, raw)
- if (!snap) {
- return
- }
-
- const info = toolFrame(props.commit, raw)
- if (snap.kind === "code") {
- return (
-
- )
- }
-
- if (snap.kind === "diff") {
- const list = snap.items
- .map((item) => {
- if (!item.diff.trim()) {
- return
- }
-
- return {
- title: item.title,
- diff: item.diff,
- filetype: toolFiletype(item.file),
- deletions: item.deletions,
- diagnostics: diagnostics(info.meta, item.file ?? ""),
- }
- })
- .filter((item): item is NonNullable => Boolean(item))
-
- if (list.length === 0) {
- return
- }
-
- return (
-
-
- {(item) => (
-
- )}
-
-
- )
- }
-
- if (snap.kind === "task") {
- return (
-
- )
- }
-
- if (snap.kind === "todo") {
- return (
-
- )
- }
-
- return (
-
- )
-}
-
export function RunEntryContent(props: {
commit: StreamCommit
theme?: RunTheme
@@ -574,102 +167,195 @@ export function RunEntryContent(props: {
width?: number
}) {
const theme = props.theme ?? RUN_THEME_FALLBACK
- if (snapCommit(props.commit)) {
- return
+ const body = entryBody(props.commit)
+ if (body.type === "none") {
+ return null
}
- return
-}
-
-function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
- return (ctx) =>
- fit(
- createScrollbackWriter(() => , {
- width: cols(ctx),
- startOnNewLine: flags.startOnNewLine,
- trailingNewline: flags.trailingNewline,
- })(ctx),
- ctx,
+ if (body.type === "text") {
+ const style = look(props.commit, theme.entry)
+ return (
+
+ {body.content}
+
)
-}
-
-function blankWriter(): ScrollbackWriter {
- return (ctx) =>
- createScrollbackWriter(() => , {
- width: cols(ctx),
- startOnNewLine: true,
- trailingNewline: true,
- })(ctx)
-}
-
-function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
- return (ctx) =>
- full(() => , ctx, {
- startOnNewLine: true,
- trailingNewline: false,
- })
-}
-
-function flags(commit: StreamCommit): Flags {
- if (commit.kind === "user") {
- return {
- startOnNewLine: true,
- trailingNewline: false,
- }
}
- if (commit.kind === "tool") {
- if (commit.phase === "progress") {
- return {
- startOnNewLine: false,
- trailingNewline: false,
- }
- }
-
- return {
- startOnNewLine: true,
- trailingNewline: true,
- }
+ if (body.type === "code") {
+ return (
+
+ )
}
- if (commit.kind === "assistant" || commit.kind === "reasoning") {
- if (commit.phase === "progress") {
- return {
- startOnNewLine: false,
- trailingNewline: false,
- }
+ if (body.type === "structured") {
+ const width = Math.max(1, Math.trunc(props.width ?? 80))
+
+ if (body.snapshot.kind === "code") {
+ return (
+
+
+ {body.snapshot.title}
+
+
+
+
+
+
+
+ )
}
- return {
- startOnNewLine: true,
- trailingNewline: true,
+ if (body.snapshot.kind === "diff") {
+ const view = toolDiffView(width, props.opts?.diffStyle)
+ return (
+
+ {body.snapshot.items.map((item) => (
+
+
+ {item.title}
+
+ {item.diff.trim() ? (
+
+
+
+ ) : (
+
+ -{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
+
+ )}
+
+ ))}
+
+ )
}
+
+ if (body.snapshot.kind === "task") {
+ return (
+
+
+ {body.snapshot.title}
+
+
+ {body.snapshot.rows.map((row) => (
+
+ {row}
+
+ ))}
+ {body.snapshot.tail ? (
+
+ {body.snapshot.tail}
+
+ ) : null}
+
+
+ )
+ }
+
+ if (body.snapshot.kind === "todo") {
+ return (
+
+
+ # Todos
+
+
+ {body.snapshot.items.map((item) => (
+
+ {todoText(item)}
+
+ ))}
+ {body.snapshot.tail ? (
+
+ {body.snapshot.tail}
+
+ ) : null}
+
+
+ )
+ }
+
+ return (
+
+
+ # Questions
+
+
+ {body.snapshot.items.map((item) => (
+
+
+ {item.question}
+
+
+ {item.answer}
+
+
+ ))}
+ {body.snapshot.tail ? (
+
+ {body.snapshot.tail}
+
+ ) : null}
+
+
+ )
}
- return {
- startOnNewLine: true,
- trailingNewline: true,
- }
+ return (
+
+ )
}
-export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter {
- return textWriter(commit, theme, flags(commit))
-}
-
-export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter {
- const snap = toolSnapshot(commit, clean(commit.text))
- if (!snap) {
- return textEntryWriter(commit, theme.entry)
- }
-
- const style = flags(commit)
-
- return (ctx) =>
- full(() => , ctx, style)
-}
-
-export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
- return textBlockWriter(clean(text), theme)
+export function entryWriter(input: {
+ commit: StreamCommit
+ theme?: RunTheme
+ opts?: ScrollbackOptions
+}): ScrollbackWriter {
+ return createScrollbackWriter(
+ // @ts-expect-error @opentui/solid scrollback helper still exposes solid-js JSX types
+ (ctx) => ,
+ entryFlags(input.commit),
+ )
}
export function spacerWriter(): ScrollbackWriter {
diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/opencode/src/cli/cmd/run/theme.ts
index cb83b7a909..344fb9f287 100644
--- a/packages/opencode/src/cli/cmd/run/theme.ts
+++ b/packages/opencode/src/cli/cmd/run/theme.ts
@@ -38,6 +38,7 @@ export type RunBlockTheme = {
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
+ subtleSyntax?: SyntaxStyle
diffAdded: ColorInput
diffRemoved: ColorInput
diffAddedBg: ColorInput
@@ -98,7 +99,7 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
)
}
-function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
+function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: SyntaxStyle): RunTheme {
const bg = theme.background
const pane = theme.backgroundElement
const shade = fade(pane, bg, 0.12, 0.56, 0.72)
@@ -145,6 +146,7 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
text: theme.text,
muted: theme.textMuted,
syntax,
+ subtleSyntax,
diffAdded: theme.diffAdded,
diffRemoved: theme.diffRemoved,
diffAddedBg: theme.diffAddedBg,
@@ -229,7 +231,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise
const mod = await import("../tui/context/theme")
const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent
try {
- return map(theme, mod.generateSyntax(theme))
+ return map(theme, mod.generateSyntax(theme), mod.generateSubtleSyntax(theme))
} catch {
return map(theme)
}
diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts
index 0e82eadef4..3321eb4a2b 100644
--- a/packages/opencode/src/cli/cmd/run/tool.ts
+++ b/packages/opencode/src/cli/cmd/run/tool.ts
@@ -18,16 +18,14 @@ import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
-import type { Tool } from "../../../tool/tool"
+import type * as Tool from "../../../tool/tool"
import type { ApplyPatchTool } from "../../../tool/apply_patch"
-import type { BatchTool } from "../../../tool/batch"
import type { BashTool } from "../../../tool/bash"
import type { CodeSearchTool } from "../../../tool/codesearch"
import type { EditTool } from "../../../tool/edit"
import type { GlobTool } from "../../../tool/glob"
import type { GrepTool } from "../../../tool/grep"
import type { InvalidTool } from "../../../tool/invalid"
-import type { ListTool } from "../../../tool/ls"
import type { LspTool } from "../../../tool/lsp"
import type { PlanExitTool } from "../../../tool/plan"
import type { QuestionTool } from "../../../tool/question"
@@ -40,7 +38,7 @@ import type { WebSearchTool } from "../../../tool/websearch"
import type { WriteTool } from "../../../tool/write"
import { LANGUAGE_EXTENSIONS } from "../../../lsp/language"
import * as Locale from "../../../util/locale"
-import type { RunDiffStyle, StreamCommit } from "./types"
+import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {
output: boolean
@@ -78,55 +76,6 @@ export type ToolPermissionInfo = {
file?: string
}
-export type ToolCodeSnapshot = {
- kind: "code"
- title: string
- content: string
- file?: string
-}
-
-export type ToolDiffSnapshot = {
- kind: "diff"
- items: Array<{
- title: string
- diff: string
- file?: string
- deletions?: number
- }>
-}
-
-export type ToolTaskSnapshot = {
- kind: "task"
- title: string
- rows: string[]
- tail: string
-}
-
-export type ToolTodoSnapshot = {
- kind: "todo"
- items: Array<{
- status: string
- content: string
- }>
- tail: string
-}
-
-export type ToolQuestionSnapshot = {
- kind: "question"
- items: Array<{
- question: string
- answer: string
- }>
- tail: string
-}
-
-export type ToolSnapshot =
- | ToolCodeSnapshot
- | ToolDiffSnapshot
- | ToolTaskSnapshot
- | ToolTodoSnapshot
- | ToolQuestionSnapshot
-
export type ToolProps = {
input: Partial>
metadata: Partial>
@@ -151,14 +100,14 @@ type ToolDefs = {
write: typeof WriteTool
edit: typeof EditTool
apply_patch: typeof ApplyPatchTool
- batch: typeof BatchTool
+ batch: Tool.Info
task: typeof TaskTool
todowrite: typeof TodoWriteTool
question: typeof QuestionTool
read: typeof ReadTool
glob: typeof GlobTool
grep: typeof GrepTool
- list: typeof ListTool
+ list: Tool.Info
lsp: typeof LspTool
webfetch: typeof WebFetchTool
codesearch: typeof CodeSearchTool
@@ -361,8 +310,8 @@ function runGrep(p: ToolProps): ToolInline {
}
}
-function runList(p: ToolProps): ToolInline {
- const dir = p.input.path ?? ""
+function runList(p: ToolProps): ToolInline {
+ const dir = text(dict(p.input).path)
return {
icon: "→",
title: dir ? `List ${toolPath(dir)}` : "List",
@@ -487,8 +436,8 @@ function runInvalid(p: ToolProps): ToolInline {
}
}
-function runBatch(p: ToolProps): ToolInline {
- const calls = list(p.input.tool_calls).length
+function runBatch(p: ToolProps): ToolInline {
+ const calls = list(dict(p.input).tool_calls).length
return {
icon: "#",
title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"),
@@ -600,7 +549,7 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefine
return
}
- const diff = typeof file.diff === "string" ? file.diff : ""
+ const diff = typeof file.patch === "string" ? file.patch : ""
if (!diff.trim()) {
return
}
@@ -865,15 +814,15 @@ function scrollTodoStart(p: ToolProps): string {
}
function scrollTodoFinal(p: ToolProps): string {
- const list = p.input.todos ?? []
- if (list.length === 0) {
+ const items = list<{ status?: string }>(p.input.todos)
+ if (items.length === 0) {
return done("todos", span(p.frame.state))
}
- const doneN = list.filter((item) => item.status === "completed").length
- const runN = list.filter((item) => item.status === "in_progress").length
- const left = list.length - doneN - runN
- const tail = [`${list.length} total`]
+ const doneN = items.filter((item) => item.status === "completed").length
+ const runN = items.filter((item) => item.status === "in_progress").length
+ const left = items.length - doneN - runN
+ const tail = [`${items.length} total`]
if (doneN > 0) {
tail.push(`${doneN} done`)
}
@@ -944,8 +893,8 @@ function scrollGrepStart(p: ToolProps): string {
return `${head} in ${toolPath(dir)}`
}
-function scrollListStart(p: ToolProps): string {
- const dir = p.input.path ?? ""
+function scrollListStart(p: ToolProps): string {
+ const dir = text(dict(p.input).path)
if (!dir) {
return "→ List"
}
@@ -1019,8 +968,8 @@ function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
}
}
-function permList(p: ToolPermissionProps): ToolPermissionInfo {
- const dir = p.input.path || p.patterns[0] || ""
+function permList(p: ToolPermissionProps): ToolPermissionInfo {
+ const dir = text(dict(p.input).path) || p.patterns[0] || ""
return {
icon: "→",
title: `List ${toolPath(dir, { home: true })}`,
@@ -1369,6 +1318,16 @@ export function toolView(name?: string): ToolView {
)
}
+export function toolStructuredFinal(commit: StreamCommit): boolean {
+ const state = commit.toolState ?? commit.part?.state.status
+ return (
+ commit.kind === "tool" &&
+ commit.phase === "final" &&
+ state === "completed" &&
+ Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
+ )
+}
+
export function toolInlineInfo(part: ToolPart): ToolInline {
const ctx = frame(part)
const draw = rule(ctx.name)?.run
@@ -1442,6 +1401,58 @@ export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot |
}
}
+function textBody(content: string): RunEntryBody | undefined {
+ if (!content) {
+ return
+ }
+
+ return {
+ type: "text",
+ content,
+ }
+}
+
+function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
+ const snap = toolSnapshot(commit, raw)
+ if (!snap) {
+ return
+ }
+
+ return {
+ type: "structured",
+ snapshot: snap,
+ }
+}
+
+export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
+ const ctx = toolFrame(commit, raw)
+ const view = toolView(ctx.name)
+
+ if (commit.phase === "progress" && !view.output) {
+ return
+ }
+
+ if (commit.phase === "final") {
+ if (ctx.status === "error") {
+ return textBody(toolScroll("final", ctx))
+ }
+
+ if (!view.final) {
+ return
+ }
+
+ if (ctx.status && ctx.status !== "completed") {
+ return textBody(ctx.raw.trim())
+ }
+
+ if (toolStructuredFinal(commit)) {
+ return structuredBody(commit, raw) ?? textBody(toolScroll("final", ctx))
+ }
+ }
+
+ return textBody(toolScroll(commit.phase, ctx))
+}
+
export function toolFiletype(input?: string): string | undefined {
if (!input) {
return
diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts
index 8a22c107ac..b2f5f50fd2 100644
--- a/packages/opencode/src/cli/cmd/run/types.ts
+++ b/packages/opencode/src/cli/cmd/run/types.ts
@@ -83,6 +83,62 @@ export type ScrollbackOptions = {
diffStyle?: RunDiffStyle
}
+export type ToolCodeSnapshot = {
+ kind: "code"
+ title: string
+ content: string
+ file?: string
+}
+
+export type ToolDiffSnapshot = {
+ kind: "diff"
+ items: Array<{
+ title: string
+ diff: string
+ file?: string
+ deletions?: number
+ }>
+}
+
+export type ToolTaskSnapshot = {
+ kind: "task"
+ title: string
+ rows: string[]
+ tail: string
+}
+
+export type ToolTodoSnapshot = {
+ kind: "todo"
+ items: Array<{
+ status: string
+ content: string
+ }>
+ tail: string
+}
+
+export type ToolQuestionSnapshot = {
+ kind: "question"
+ items: Array<{
+ question: string
+ answer: string
+ }>
+ tail: string
+}
+
+export type ToolSnapshot =
+ | ToolCodeSnapshot
+ | ToolDiffSnapshot
+ | ToolTaskSnapshot
+ | ToolTodoSnapshot
+ | ToolQuestionSnapshot
+
+export type RunEntryBody =
+ | { type: "none" }
+ | { type: "text"; content: string }
+ | { type: "code"; content: string; filetype?: string }
+ | { type: "markdown"; content: string }
+ | { type: "structured"; snapshot: ToolSnapshot }
+
// Which interactive surface the footer is showing. Only one view is active at
// a time. The reducer drives transitions: when a permission arrives the view
// switches to "permission", and when the permission resolves it falls back to
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index ef4a6bb7ba..acbaa682ae 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -715,7 +715,7 @@ export function generateSyntax(theme: TuiThemeCurrent) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
-function generateSubtleSyntax(theme: TuiThemeCurrent) {
+export function generateSubtleSyntax(theme: TuiThemeCurrent) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts
new file mode 100644
index 0000000000..be6df3ad6e
--- /dev/null
+++ b/packages/opencode/test/cli/run/entry.body.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, test } from "bun:test"
+import { entryBody, entryCanStream, entryDone } from "../../../src/cli/cmd/run/entry.body"
+import type { StreamCommit } from "../../../src/cli/cmd/run/types"
+
+function commit(input: Partial & Pick): StreamCommit {
+ return input
+}
+
+describe("run entry body", () => {
+ test("renders assistant progress as markdown", () => {
+ 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**",
+ })
+ })
+
+ test("renders reasoning as markdown-highlighted code like the tui", () => {
+ const body = entryBody(
+ commit({
+ kind: "reasoning",
+ text: "Thinking: plan next steps",
+ phase: "progress",
+ source: "reasoning",
+ partID: "reason-1",
+ }),
+ )
+
+ expect(body).toEqual({
+ type: "code",
+ filetype: "markdown",
+ content: "_Thinking:_ plan next steps",
+ })
+ expect(entryCanStream(commit({ kind: "reasoning", text: "Thinking: plan next steps", phase: "progress", source: "reasoning" }), body)).toBe(true)
+ })
+
+ test("prefixes user entries in text mode", () => {
+ expect(
+ entryBody(
+ commit({
+ kind: "user",
+ text: "Inspect footer tabs",
+ phase: "start",
+ source: "system",
+ }),
+ ),
+ ).toEqual({
+ type: "text",
+ content: "› Inspect footer tabs",
+ })
+ })
+
+ test("keeps completed write tool finals structured", () => {
+ const body = entryBody(
+ commit({
+ kind: "tool",
+ text: "",
+ phase: "final",
+ source: "tool",
+ tool: "write",
+ toolState: "completed",
+ part: {
+ id: "tool-1",
+ sessionID: "session-1",
+ messageID: "msg-1",
+ type: "tool",
+ callID: "call-1",
+ tool: "write",
+ state: {
+ status: "completed",
+ input: {
+ filePath: "src/a.ts",
+ content: "const x = 1\n",
+ },
+ metadata: {},
+ 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: "code",
+ title: "# Wrote src/a.ts",
+ content: "const x = 1\n",
+ file: "src/a.ts",
+ })
+ expect(entryDone(
+ commit({
+ kind: "tool",
+ text: "output",
+ phase: "progress",
+ source: "tool",
+ tool: "bash",
+ toolState: "completed",
+ }),
+ )).toBe(true)
+ })
+
+ test("streams tool progress text", () => {
+ 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)
+ })
+
+ 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",
+ })
+ })
+})
diff --git a/packages/opencode/test/cli/run/footer.test.ts b/packages/opencode/test/cli/run/footer.test.ts
index 779c575cf8..ac47f81b31 100644
--- a/packages/opencode/test/cli/run/footer.test.ts
+++ b/packages/opencode/test/cli/run/footer.test.ts
@@ -1,6 +1,150 @@
-import { expect, test } from "bun:test"
+import { afterEach, expect, test } from "bun:test"
+import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { RunFooter } from "../../../src/cli/cmd/run/footer"
+import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
+
+const decoder = new TextDecoder()
+const active: Array<{ footer?: RunFooter; renderer: TestRenderer }> = []
+
+afterEach(() => {
+ for (const item of active.splice(0)) {
+ item.footer?.destroy()
+ item.renderer.destroy()
+ }
+})
+
+function createFooter(renderer: TestRenderer) {
+ const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+ treeSitterClient.setMockResult({ highlights: [] })
+
+ return new RunFooter(renderer, {
+ directory: "/tmp",
+ findFiles: async () => [],
+ agents: [],
+ resources: [],
+ agentLabel: "Build",
+ modelLabel: "Model default",
+ first: false,
+ history: [],
+ theme: RUN_THEME_FALLBACK,
+ keybinds: {
+ leader: "",
+ variantCycle: "tab",
+ interrupt: "esc",
+ historyPrevious: "up",
+ historyNext: "down",
+ inputSubmit: "enter",
+ inputNewline: "shift+enter",
+ },
+ diffStyle: "auto",
+ onPermissionReply: () => {},
+ onQuestionReply: () => {},
+ onQuestionReject: () => {},
+ treeSitterClient,
+ })
+}
test("run footer class loads", () => {
expect(typeof RunFooter).toBe("function")
})
+
+test("run footer finalizes streamed markdown tables when the turn goes idle", async () => {
+ const out = await createTestRenderer({
+ width: 80,
+ height: 24,
+ screenMode: "split-footer",
+ footerHeight: 6,
+ externalOutputMode: "capture-stdout",
+ consoleMode: "disabled",
+ })
+ const footer = createFooter(out.renderer)
+ active.push({ footer, renderer: out.renderer })
+ const lib = Reflect.get(out.renderer, "lib") as {
+ commitSplitFooterSnapshot: (...args: unknown[]) => unknown
+ }
+ const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
+ let payload = ""
+
+ lib.commitSplitFooterSnapshot = (...args: unknown[]) => {
+ const snapshot = args[1] as {
+ getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+ }
+ payload += decoder.decode(snapshot.getRealCharBytes(true))
+ return originalCommitSplitFooterSnapshot(...args)
+ }
+
+ try {
+ footer.event({ type: "turn.send", queue: 0 })
+
+ 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 chunk of text) {
+ footer.append({
+ kind: "assistant",
+ text: chunk,
+ phase: "progress",
+ source: "assistant",
+ messageID: "msg-1",
+ partID: "part-1",
+ })
+ }
+
+ footer.event({ type: "turn.idle", queue: 0 })
+ await footer.idle()
+
+ expect(payload).toContain("Column 1")
+ expect(payload).toContain("Row 2")
+ expect(payload).toContain("Value 4")
+ } finally {
+ lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
+ }
+})
+
+test("run footer keeps active streamed assistant content across width resize", async () => {
+ const out = await createTestRenderer({
+ width: 40,
+ height: 24,
+ screenMode: "split-footer",
+ footerHeight: 6,
+ externalOutputMode: "capture-stdout",
+ consoleMode: "disabled",
+ })
+ const footer = createFooter(out.renderer)
+ active.push({ footer, renderer: out.renderer })
+ const lib = Reflect.get(out.renderer, "lib") as {
+ commitSplitFooterSnapshot: (...args: unknown[]) => unknown
+ }
+ const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
+ let payload = ""
+
+ lib.commitSplitFooterSnapshot = (...args: unknown[]) => {
+ const snapshot = args[1] as {
+ getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+ }
+ payload += decoder.decode(snapshot.getRealCharBytes(true))
+ return originalCommitSplitFooterSnapshot(...args)
+ }
+
+ try {
+ footer.event({ type: "turn.send", queue: 0 })
+
+ footer.append({
+ kind: "assistant",
+ text: "This paragraph only existed in the active surface until finalization.",
+ phase: "progress",
+ source: "assistant",
+ messageID: "msg-2",
+ partID: "part-2",
+ })
+
+ out.resize(60, 24)
+
+ footer.event({ type: "turn.idle", queue: 0 })
+ await footer.idle()
+
+ expect(payload.replace(/\s+/g, " ").trim()).toContain(
+ "This paragraph only existed in the active surface until finalization.",
+ )
+ } finally {
+ lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
+ }
+})
diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts
new file mode 100644
index 0000000000..c4fc26ac9a
--- /dev/null
+++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts
@@ -0,0 +1,225 @@
+import { afterEach, expect, test } from "bun:test"
+import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
+import { RunScrollbackStream } from "../../../src/cli/cmd/run/scrollback.surface"
+import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
+
+type ClaimedCommit = {
+ snapshot: {
+ getRealCharBytes(addLineBreaks?: boolean): Uint8Array
+ destroy(): void
+ }
+}
+
+const decoder = new TextDecoder()
+const active: TestRenderer[] = []
+
+afterEach(() => {
+ for (const renderer of active.splice(0)) {
+ renderer.destroy()
+ }
+})
+
+function claimCommits(renderer: TestRenderer): ClaimedCommit[] {
+ return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[]
+}
+
+function destroyCommits(commits: ClaimedCommit[]) {
+ for (const commit of commits) {
+ commit.snapshot.destroy()
+ }
+}
+
+test("completes finely streamed markdown tables when the turn goes idle", async () => {
+ const out = await createTestRenderer({
+ 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,
+ })
+
+ 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 chunk of text) {
+ await scrollback.append({
+ kind: "assistant",
+ text: chunk,
+ phase: "progress",
+ source: "assistant",
+ messageID: "msg-1",
+ partID: "part-1",
+ })
+ }
+
+ await scrollback.complete()
+
+ const commits = claimCommits(out.renderer)
+ try {
+ expect(commits.length).toBeGreaterThan(0)
+ const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n")
+ expect(rendered).toContain("Column 1")
+ expect(rendered).toContain("Row 2")
+ expect(rendered).toContain("Value 4")
+ } finally {
+ destroyCommits(commits)
+ }
+})
+
+test("completes coalesced markdown tables after one progress append", async () => {
+ const out = await createTestRenderer({
+ 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,
+ })
+
+ await scrollback.append({
+ kind: "assistant",
+ text: "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |",
+ phase: "progress",
+ source: "assistant",
+ messageID: "msg-1",
+ partID: "part-1",
+ })
+
+ await scrollback.complete()
+
+ const commits = claimCommits(out.renderer)
+ try {
+ expect(commits.length).toBeGreaterThan(0)
+ const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n")
+ expect(rendered).toContain("Column 1")
+ expect(rendered).toContain("Row 2")
+ expect(rendered).toContain("Value 4")
+ } finally {
+ destroyCommits(commits)
+ }
+})
+
+test("coalesces same-line tool progress into one snapshot", async () => {
+ const out = await createTestRenderer({
+ width: 80,
+ screenMode: "split-footer",
+ footerHeight: 6,
+ externalOutputMode: "capture-stdout",
+ consoleMode: "disabled",
+ })
+ active.push(out.renderer)
+
+ const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+ wrote: false,
+ })
+
+ await scrollback.append({
+ kind: "tool",
+ text: "abc",
+ phase: "progress",
+ source: "tool",
+ partID: "tool-1",
+ messageID: "msg-1",
+ tool: "bash",
+ })
+ await scrollback.append({
+ kind: "tool",
+ text: "def",
+ phase: "progress",
+ source: "tool",
+ partID: "tool-1",
+ messageID: "msg-1",
+ tool: "bash",
+ })
+ await scrollback.append({
+ kind: "tool",
+ text: "",
+ phase: "final",
+ source: "tool",
+ partID: "tool-1",
+ messageID: "msg-1",
+ tool: "bash",
+ toolState: "completed",
+ })
+
+ const commits = claimCommits(out.renderer)
+ try {
+ expect(commits).toHaveLength(1)
+ expect(decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))).toContain("abcdef")
+ } finally {
+ destroyCommits(commits)
+ }
+})
+
+test("renders structured write finals as native code blocks", 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: "tool-2",
+ messageID: "msg-2",
+ tool: "write",
+ toolState: "completed",
+ part: {
+ id: "tool-2",
+ sessionID: "session-1",
+ messageID: "msg-2",
+ type: "tool",
+ callID: "call-2",
+ tool: "write",
+ state: {
+ status: "completed",
+ input: {
+ filePath: "src/a.ts",
+ content: "const x = 1\nconst y = 2\n",
+ },
+ metadata: {},
+ time: {
+ start: 1,
+ end: 2,
+ },
+ },
+ } as never,
+ })
+
+ const commits = claimCommits(out.renderer)
+ try {
+ expect(commits).toHaveLength(1)
+ const rendered = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true)).replace(/ +/g, " ")
+ expect(rendered).toContain("# Wrote src/a.ts")
+ expect(rendered).toMatch(/1\s+const x = 1/)
+ expect(rendered).toMatch(/2\s+const y = 2/)
+ } finally {
+ destroyCommits(commits)
+ }
+})
diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts
index 0fcdb6bdda..7e80db899b 100644
--- a/packages/opencode/test/cli/run/subagent-data.test.ts
+++ b/packages/opencode/test/cli/run/subagent-data.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { normalizeEntry } from "../../../src/cli/cmd/run/scrollback.format"
+import { entryBody } from "../../../src/cli/cmd/run/entry.body"
import {
bootstrapSubagentData,
clearFinishedSubagents,
@@ -9,6 +9,33 @@ import {
snapshotSubagentData,
} from "../../../src/cli/cmd/run/subagent-data"
+function visible(commits: Array[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 taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") {
return {
info: {
@@ -243,9 +270,9 @@ describe("run subagent data", () => {
sessionID: "child-1",
commits: expect.any(Array),
})
- expect(snapshot.details["child-1"]?.commits.map((item) => normalizeEntry(item))).toEqual([
+ expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
"› Inspect footer tabs",
- "Thinking: planning next steps",
+ "_Thinking:_ planning next steps",
"# Shell\n$ git status --short",
])
expect(snapshot.permissions).toEqual([
@@ -343,9 +370,9 @@ describe("run subagent data", () => {
} as never,
})
- expect(
- snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits.map((item) => normalizeEntry(item)),
- ).toEqual(["hello world"])
+ expect(visible(snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits ?? [])).toEqual([
+ "hello world",
+ ])
})
test("clears finished tabs on the next parent prompt", () => {