From 57475cc9db5bad3da93b098d4e579702ffa7a1e6 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 18:38:04 -0500 Subject: [PATCH 01/13] feat(tui): open execute call details dialog on click --- .../tui/src/routes/session/dialog-execute.tsx | 200 ++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 24 +-- packages/tui/src/util/tool-display.ts | 19 ++ 3 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 packages/tui/src/routes/session/dialog-execute.tsx diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx new file mode 100644 index 000000000000..84e13af978b6 --- /dev/null +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -0,0 +1,200 @@ +import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import type { SessionMessageAssistantTool } from "@opencode/client/promise" +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import stripAnsi from "strip-ansi" +import { useConfig } from "../../config" +import { useClipboard } from "../../context/clipboard" +import { Keymap } from "../../context/keymap" +import { useTheme, useThemes } from "../../context/theme" +import { useDialog } from "../../ui/dialog" +import { useToast } from "../../ui/toast" +import { Locale } from "../../util/locale" +import { getScrollAcceleration } from "../../util/scroll" +import { executeCalls, executeCallSummary, toolDisplayContent, toolDisplayMetadata } from "../../util/tool-display" + +// The part is passed as a live accessor prop so the dialog follows the tool +// while child calls stream and the output arrives. +export function DialogExecute(props: { part: SessionMessageAssistantTool }) { + const dialog = useDialog() + const clipboard = useClipboard() + const toast = useToast() + const theme = useTheme("elevated") + const { currentSyntax: syntax } = useThemes() + const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const config = useConfig().data + const [copied, setCopied] = createSignal<"code" | "output">() + const [height, setHeight] = createSignal(1) + const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height * 0.7) - 6)) + let scroll: ScrollBoxRenderable | undefined + let measure: (() => void) | undefined + + dialog.setSize("xlarge") + dialog.setCentered(true) + + const code = createMemo(() => { + const input = props.part.state.input + if (typeof input === "string") return "" + return typeof input.code === "string" ? input.code : "" + }) + const metadata = createMemo(() => toolDisplayMetadata(props.part.state)) + const calls = createMemo(() => executeCalls(metadata().toolCalls)) + const failed = createMemo(() => metadata().error === true || props.part.state.status === "error") + const output = createMemo(() => { + const state = props.part.state + if (state.status === "error") return state.error.message + return stripAnsi( + toolDisplayContent(state) + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .join("\n") + .trim(), + ) + }) + const status = createMemo(() => { + const state = props.part.state + if (state.status === "streaming") return "Receiving code…" + if (state.status === "running") return "Running" + const duration = props.part.time.completed + ? ` · ${Locale.duration(props.part.time.completed - (props.part.time.ran ?? props.part.time.created))}` + : "" + if (failed()) return `Failed${duration}` + return `Completed${duration}` + }) + + // Size the scroll area to its content up to the cap so short calls do not + // leave a mostly empty dialog; remeasure as the part streams in. + createEffect(() => { + dimensions() + code() + calls() + output() + if (measure) renderer.off(CliRenderEvents.FRAME, measure) + measure = () => { + measure = undefined + if (!scroll) return + setHeight(Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))) + } + renderer.once(CliRenderEvents.FRAME, measure) + renderer.requestRender() + }) + + onCleanup(() => { + if (measure) renderer.off(CliRenderEvents.FRAME, measure) + }) + + const copy = (kind: "code" | "output") => { + const text = kind === "code" ? code() : output() + if (!text) return + void clipboard + .write(text) + .then(() => setCopied(kind)) + .catch(toast.error) + } + + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "up", title: "Scroll up", group: "Execute", run: () => scroll?.scrollBy(-1) }, + { bind: "down", title: "Scroll down", group: "Execute", run: () => scroll?.scrollBy(1) }, + { bind: "pageup", title: "Previous page", group: "Execute", run: () => scroll?.scrollBy(-height()) }, + { bind: "pagedown", title: "Next page", group: "Execute", run: () => scroll?.scrollBy(height()) }, + { bind: "home", title: "Scroll to code", group: "Execute", run: () => scroll?.scrollTo(0) }, + { bind: "end", title: "Scroll to output", group: "Execute", run: () => scroll?.scrollTo(Infinity) }, + { bind: "c", title: "Copy code", group: "Execute", run: () => copy("code") }, + { bind: "o", title: "Copy output", group: "Execute", run: () => copy("output") }, + ], + })) + + return ( + + + + execute + + {status()} + dialog.clear()}> + esc + + + (scroll = value)} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={getScrollAcceleration(config)} + > + + + + Code + + Waiting for code…}> + + + + + + 0}> + + + Tool calls + + + {(call) => ( + + {call.status === "error" ? "✗ " : call.status === "running" ? "│ " : "› "} + {executeCallSummary(call)} + + )} + + + + + + Output + + + {props.part.state.status === "completed" ? "No output" : "Waiting for output…"} + + } + > + + {output()} + + + + + + + ↑/↓ scroll + copy("code")}> + + {copied() === "code" ? "✓ copied" : "c"} + + {copied() === "code" ? "" : " copy code"} + + copy("output")}> + + {copied() === "output" ? "✓ copied" : "o"} + + {copied() === "output" ? "" : " copy output"} + + esc back + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9e251742ed9c..08ff6efb57aa 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -40,11 +40,15 @@ import { Locale } from "../../util/locale" import { FilePath } from "../../ui/file-path" import { canonicalToolName, + executeCalls, + executeCallSummary, finiteNumber, primitiveInputSummary, toolDisplayContent, toolDisplayMetadata, + type ExecuteCall, } from "../../util/tool-display" +import { DialogExecute } from "./dialog-execute" import { RetryProvider } from "../../component/retry-provider" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useClient } from "../../context/client" @@ -3178,23 +3182,7 @@ export function isBackgroundSubagent( return status === "completed" && metadata.status === "running" } -type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record } - -function executeCalls(value: unknown): ExecuteCall[] { - if (!Array.isArray(value)) return [] - return value.flatMap((call) => { - const item = recordValue(call) - const tool = stringValue(item?.tool) - const status = stringValue(item?.status) - if (!tool || !status || !["running", "completed", "error"].includes(status)) return [] - return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }] - }) -} - -export function executeCallSummary(call: ExecuteCall) { - const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ") - return `${call.tool}${args ? ` ${args}` : ""}` -} +export { executeCallSummary } function ExecuteCallView(props: { call: Accessor }) { const theme = useTheme() @@ -3252,6 +3240,7 @@ function ExecuteCallView(props: { call: Accessor }) { function Execute(props: ToolProps) { const ctx = use() const theme = useTheme() + const dialog = useDialog() const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running") const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) @@ -3268,6 +3257,7 @@ function Execute(props: ToolProps) { pending="execute" complete={true} part={props.part} + onClick={() => dialog.replace(() => )} > execute diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index 4a6268601962..8916eb123f1e 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -19,6 +19,24 @@ export function primitiveInputSummary(input: Record, omit: read return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]` } +export type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record } + +export function executeCalls(value: unknown): ExecuteCall[] { + if (!Array.isArray(value)) return [] + return value.flatMap((call) => { + if (!isRecord(call)) return [] + const tool = call.tool + const status = call.status + if (typeof tool !== "string" || (status !== "running" && status !== "completed" && status !== "error")) return [] + return [{ tool, status, input: isRecord(call.input) ? call.input : undefined }] + }) +} + +export function executeCallSummary(call: ExecuteCall) { + const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ") + return `${call.tool}${args ? ` ${args}` : ""}` +} + export function webSearchProviderName(provider: unknown) { if (typeof provider !== "string" || !provider) return "" return `${provider[0].toUpperCase()}${provider.slice(1)}` @@ -48,3 +66,4 @@ export function nonEmptyToolContent(content: ReadonlyArray | undefined): [ return first === undefined ? undefined : [first, ...rest] } import type { SessionMessageAssistantTool } from "@opencode/client/promise" +import { isRecord } from "./record" From d88d82c323357bc60719057c664be994534adec2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 19:01:55 -0500 Subject: [PATCH 02/13] fix(tui): fit execute dialog to wrapped code height --- .../tui/src/routes/session/dialog-execute.tsx | 43 ++++++++----------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 84e13af978b6..5dff738bbbec 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,7 +1,7 @@ import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show, untrack } from "solid-js" import stripAnsi from "strip-ansi" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" @@ -28,7 +28,21 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const [height, setHeight] = createSignal(1) const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height * 0.7) - 6)) let scroll: ScrollBoxRenderable | undefined - let measure: (() => void) | undefined + + // Fit the scroll area to its content up to the cap. Wrapped code settles a + // frame after mount and output streams in, so grow from the measured height + // on every frame instead of measuring once; a resize restarts the fit. + createEffect(() => { + dimensions() + setHeight(1) + }) + const measure = () => { + if (!scroll) return + const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight)) + if (next > untrack(height)) setHeight(next) + } + renderer.on(CliRenderEvents.FRAME, measure) + onCleanup(() => renderer.off(CliRenderEvents.FRAME, measure)) dialog.setSize("xlarge") dialog.setCentered(true) @@ -62,27 +76,6 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { return `Completed${duration}` }) - // Size the scroll area to its content up to the cap so short calls do not - // leave a mostly empty dialog; remeasure as the part streams in. - createEffect(() => { - dimensions() - code() - calls() - output() - if (measure) renderer.off(CliRenderEvents.FRAME, measure) - measure = () => { - measure = undefined - if (!scroll) return - setHeight(Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))) - } - renderer.once(CliRenderEvents.FRAME, measure) - renderer.requestRender() - }) - - onCleanup(() => { - if (measure) renderer.off(CliRenderEvents.FRAME, measure) - }) - const copy = (kind: "code" | "output") => { const text = kind === "code" ? code() : output() if (!text) return @@ -97,8 +90,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { commands: [ { bind: "up", title: "Scroll up", group: "Execute", run: () => scroll?.scrollBy(-1) }, { bind: "down", title: "Scroll down", group: "Execute", run: () => scroll?.scrollBy(1) }, - { bind: "pageup", title: "Previous page", group: "Execute", run: () => scroll?.scrollBy(-height()) }, - { bind: "pagedown", title: "Next page", group: "Execute", run: () => scroll?.scrollBy(height()) }, + { bind: "pageup", title: "Previous page", group: "Execute", run: () => scroll?.scrollBy(-maxHeight()) }, + { bind: "pagedown", title: "Next page", group: "Execute", run: () => scroll?.scrollBy(maxHeight()) }, { bind: "home", title: "Scroll to code", group: "Execute", run: () => scroll?.scrollTo(0) }, { bind: "end", title: "Scroll to output", group: "Execute", run: () => scroll?.scrollTo(Infinity) }, { bind: "c", title: "Copy code", group: "Execute", run: () => copy("code") }, From e72ba3b74e48e47514a0cf0bdea4eae913091683 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 19:34:01 -0500 Subject: [PATCH 03/13] feat(tui): prettify execute code and drop tool call list from dialog --- bun.lock | 1 + packages/tui/package.json | 1 + .../tui/src/routes/session/dialog-execute.tsx | 61 +++++++++++-------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 363e99b69b2c..2687075833aa 100644 --- a/bun.lock +++ b/bun.lock @@ -915,6 +915,7 @@ "get-east-asian-width": "catalog:", "open": "10.1.2", "opentui-spinner": "catalog:", + "prettier": "3.6.2", "remeda": "catalog:", "solid-js": "catalog:", "string-width": "catalog:", diff --git a/packages/tui/package.json b/packages/tui/package.json index 4e4442bdf429..e8d14dbf1472 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -95,6 +95,7 @@ "get-east-asian-width": "catalog:", "open": "10.1.2", "opentui-spinner": "catalog:", + "prettier": "3.6.2", "remeda": "catalog:", "solid-js": "catalog:", "string-width": "catalog:", diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 5dff738bbbec..7ab14254386e 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,17 +1,17 @@ import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" -import { createEffect, createMemo, createSignal, For, onCleanup, Show, untrack } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onCleanup, Show, untrack } from "solid-js" import stripAnsi from "strip-ansi" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" import { Keymap } from "../../context/keymap" import { useTheme, useThemes } from "../../context/theme" -import { useDialog } from "../../ui/dialog" +import { dialogWidth, useDialog } from "../../ui/dialog" import { useToast } from "../../ui/toast" import { Locale } from "../../util/locale" import { getScrollAcceleration } from "../../util/scroll" -import { executeCalls, executeCallSummary, toolDisplayContent, toolDisplayMetadata } from "../../util/tool-display" +import { toolDisplayContent, toolDisplayMetadata } from "../../util/tool-display" // The part is passed as a live accessor prop so the dialog follows the tool // while child calls stream and the output arrives. @@ -52,9 +52,20 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { if (typeof input === "string") return "" return typeof input.code === "string" ? input.code : "" }) - const metadata = createMemo(() => toolDisplayMetadata(props.part.state)) - const calls = createMemo(() => executeCalls(metadata().toolCalls)) - const failed = createMemo(() => metadata().error === true || props.part.state.status === "error") + // Models often emit the program on one line. Reformat it for reading and fall + // back to the raw source while formatting runs or when it does not parse yet. + const printWidth = createMemo(() => Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 8) + const [formatted] = createResource( + () => [code(), printWidth()] as const, + async ([source, width]) => ({ source, text: await prettify(source, width) }), + ) + const display = createMemo(() => { + const result = formatted.latest + return result?.source === code() ? result.text : code() + }) + const failed = createMemo( + () => toolDisplayMetadata(props.part.state).error === true || props.part.state.status === "error", + ) const output = createMemo(() => { const state = props.part.state if (state.status === "error") return state.error.message @@ -77,7 +88,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { }) const copy = (kind: "code" | "output") => { - const text = kind === "code" ? code() : output() + const text = kind === "code" ? display() : output() if (!text) return void clipboard .write(text) @@ -129,30 +140,11 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { fg={theme.text.default} filetype="typescript" syntaxStyle={syntax()} - content={code()} + content={display()} /> - 0}> - - - Tool calls - - - {(call) => ( - - {call.status === "error" ? "✗ " : call.status === "running" ? "│ " : "› "} - {executeCallSummary(call)} - - )} - - - Output @@ -191,3 +183,18 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ) } + +async function prettify(source: string, printWidth: number) { + if (!source) return source + const { format } = await import("prettier/standalone") + const { default: babel } = await import("prettier/plugins/babel") + const { default: estree } = await import("prettier/plugins/estree") + return format(source, { + parser: "babel", + plugins: [babel, estree], + printWidth: Math.max(40, printWidth), + semi: false, + }) + .then((text) => text.trimEnd()) + .catch(() => source) +} From 25b1068f1a8b4578d586bce66d6153e219462305 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 21:27:20 -0500 Subject: [PATCH 04/13] refactor(tui): drop prettier from execute dialog and highlight JSON output --- bun.lock | 1 - packages/tui/package.json | 1 - .../tui/src/routes/session/dialog-execute.tsx | 65 +++++++++---------- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/bun.lock b/bun.lock index 2687075833aa..363e99b69b2c 100644 --- a/bun.lock +++ b/bun.lock @@ -915,7 +915,6 @@ "get-east-asian-width": "catalog:", "open": "10.1.2", "opentui-spinner": "catalog:", - "prettier": "3.6.2", "remeda": "catalog:", "solid-js": "catalog:", "string-width": "catalog:", diff --git a/packages/tui/package.json b/packages/tui/package.json index e8d14dbf1472..4e4442bdf429 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -95,7 +95,6 @@ "get-east-asian-width": "catalog:", "open": "10.1.2", "opentui-spinner": "catalog:", - "prettier": "3.6.2", "remeda": "catalog:", "solid-js": "catalog:", "string-width": "catalog:", diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 7ab14254386e..e9695e008b3b 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,18 +1,21 @@ import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" -import { createEffect, createMemo, createResource, createSignal, onCleanup, Show, untrack } from "solid-js" +import { Option, Schema } from "effect" +import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js" import stripAnsi from "strip-ansi" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" import { Keymap } from "../../context/keymap" import { useTheme, useThemes } from "../../context/theme" -import { dialogWidth, useDialog } from "../../ui/dialog" +import { useDialog } from "../../ui/dialog" import { useToast } from "../../ui/toast" import { Locale } from "../../util/locale" import { getScrollAcceleration } from "../../util/scroll" import { toolDisplayContent, toolDisplayMetadata } from "../../util/tool-display" +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) + // The part is passed as a live accessor prop so the dialog follows the tool // while child calls stream and the output arrives. export function DialogExecute(props: { part: SessionMessageAssistantTool }) { @@ -52,17 +55,6 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { if (typeof input === "string") return "" return typeof input.code === "string" ? input.code : "" }) - // Models often emit the program on one line. Reformat it for reading and fall - // back to the raw source while formatting runs or when it does not parse yet. - const printWidth = createMemo(() => Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 8) - const [formatted] = createResource( - () => [code(), printWidth()] as const, - async ([source, width]) => ({ source, text: await prettify(source, width) }), - ) - const display = createMemo(() => { - const result = formatted.latest - return result?.source === code() ? result.text : code() - }) const failed = createMemo( () => toolDisplayMetadata(props.part.state).error === true || props.part.state.status === "error", ) @@ -76,6 +68,17 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { .trim(), ) }) + // The tool prints a JSON result, optionally followed by "\n\nWarnings:" and + // "\n\nLogs:" sections. Highlight the JSON and keep any trailing text plain. + const sections = createMemo(() => { + const text = output() + if (!text.startsWith("{") && !text.startsWith("[")) return { json: "", rest: text } + const end = text.search(/\n\n(Warnings|Logs):\n/) + const json = end === -1 ? text : text.slice(0, end) + const parsed = decodeJson(json) + if (Option.isNone(parsed)) return { json: "", rest: text } + return { json, rest: text.slice(json.length).trim() } + }) const status = createMemo(() => { const state = props.part.state if (state.status === "streaming") return "Receiving code…" @@ -88,7 +91,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { }) const copy = (kind: "code" | "output") => { - const text = kind === "code" ? display() : output() + const text = kind === "code" ? code() : output() if (!text) return void clipboard .write(text) @@ -140,7 +143,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { fg={theme.text.default} filetype="typescript" syntaxStyle={syntax()} - content={display()} + content={code()} /> @@ -157,9 +160,20 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { } > - - {output()} - + + + + + + {sections().rest} + + @@ -183,18 +197,3 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ) } - -async function prettify(source: string, printWidth: number) { - if (!source) return source - const { format } = await import("prettier/standalone") - const { default: babel } = await import("prettier/plugins/babel") - const { default: estree } = await import("prettier/plugins/estree") - return format(source, { - parser: "babel", - plugins: [babel, estree], - printWidth: Math.max(40, printWidth), - semi: false, - }) - .then((text) => text.trimEnd()) - .catch(() => source) -} From 8c8322986d9c63ab983d930536a5ebe254147c90 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 21:54:02 -0500 Subject: [PATCH 05/13] fix(tui): stop wrapping code in execute dialog and pan horizontally --- packages/tui/src/routes/session/dialog-execute.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index e9695e008b3b..7528385869c4 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,4 +1,4 @@ -import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { CliRenderEvents, TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core" import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" import { Option, Schema } from "effect" @@ -31,6 +31,9 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const [height, setHeight] = createSignal(1) const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height * 0.7) - 6)) let scroll: ScrollBoxRenderable | undefined + // Unwrapped clips long lines and scrolls them itself, so pan both blocks together. + const blocks = new Set() + const pan = (delta: number) => blocks.forEach((block) => (block.scrollX += delta)) // Fit the scroll area to its content up to the cap. Wrapped code settles a // frame after mount and output streams in, so grow from the measured height @@ -106,6 +109,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { { bind: "down", title: "Scroll down", group: "Execute", run: () => scroll?.scrollBy(1) }, { bind: "pageup", title: "Previous page", group: "Execute", run: () => scroll?.scrollBy(-maxHeight()) }, { bind: "pagedown", title: "Next page", group: "Execute", run: () => scroll?.scrollBy(maxHeight()) }, + { bind: "left", title: "Scroll left", group: "Execute", run: () => pan(-8) }, + { bind: "right", title: "Scroll right", group: "Execute", run: () => pan(8) }, { bind: "home", title: "Scroll to code", group: "Execute", run: () => scroll?.scrollTo(0) }, { bind: "end", title: "Scroll to output", group: "Execute", run: () => scroll?.scrollTo(Infinity) }, { bind: "c", title: "Copy code", group: "Execute", run: () => copy("code") }, @@ -139,7 +144,9 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { Waiting for code…}> blocks.add(block)} conceal={false} + wrapMode="none" fg={theme.text.default} filetype="typescript" syntaxStyle={syntax()} @@ -162,7 +169,9 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { > blocks.add(block)} conceal={false} + wrapMode="none" fg={theme.text.default} filetype="json" syntaxStyle={syntax()} @@ -179,7 +188,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { - ↑/↓ scroll + ↑/↓ ←/→ scroll copy("code")}> {copied() === "code" ? "✓ copied" : "c"} From c3285879d99e863495d9ab56ffdf33600a5e1d75 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 22:29:57 -0500 Subject: [PATCH 06/13] feat(tui): number execute output lines and align gutters --- .../tui/src/routes/session/dialog-execute.tsx | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 7528385869c4..a4def7f5c29f 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -118,6 +118,11 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ], })) + // The gutter is digits + 2 wide and its minWidth is fixed at construction, so + // pad the block with fewer digits to keep code and output on one column. + const digits = (text: string) => String(text ? text.split("\n").length : 0).length + const pad = (own: string, other: string) => Math.max(0, digits(other) - digits(own)) + return ( @@ -142,17 +147,19 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { Code Waiting for code…}> - - blocks.add(block)} - conceal={false} - wrapMode="none" - fg={theme.text.default} - filetype="typescript" - syntaxStyle={syntax()} - content={code()} - /> - + + + blocks.add(block)} + conceal={false} + wrapMode="none" + fg={theme.text.default} + filetype="typescript" + syntaxStyle={syntax()} + content={code()} + /> + + @@ -168,15 +175,19 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { } > - blocks.add(block)} - conceal={false} - wrapMode="none" - fg={theme.text.default} - filetype="json" - syntaxStyle={syntax()} - content={sections().json} - /> + + + blocks.add(block)} + conceal={false} + wrapMode="none" + fg={theme.text.default} + filetype="json" + syntaxStyle={syntax()} + content={sections().json} + /> + + From 2cab8e21f949a316c8c9185fe42ac85873ea69d1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 22:58:52 -0500 Subject: [PATCH 07/13] fix(tui): pan execute dialog blocks from one shared offset --- packages/tui/src/routes/session/dialog-execute.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index a4def7f5c29f..765e128b8d43 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -31,9 +31,16 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const [height, setHeight] = createSignal(1) const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height * 0.7) - 6)) let scroll: ScrollBoxRenderable | undefined - // Unwrapped clips long lines and scrolls them itself, so pan both blocks together. + // Unwrapped clips long lines and scrolls them itself. Each block clamps + // to its own width, so drive both from one shared offset or the narrower block + // stops early and the two drift apart on the way back. const blocks = new Set() - const pan = (delta: number) => blocks.forEach((block) => (block.scrollX += delta)) + let panX = 0 + const pan = (delta: number) => { + const max = Math.max(0, ...[...blocks].map((block) => block.scrollWidth - block.width)) + panX = Math.max(0, Math.min(max, panX + delta)) + blocks.forEach((block) => (block.scrollX = panX)) + } // Fit the scroll area to its content up to the cap. Wrapped code settles a // frame after mount and output streams in, so grow from the measured height From 4b46d2ae9fb43dcaf775f91dda84174be51fcfcb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 23:40:16 -0500 Subject: [PATCH 08/13] fix(tui): size execute dialog synchronously from line counts --- .../tui/src/routes/session/dialog-execute.tsx | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 765e128b8d43..48bf59d6bf73 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,14 +1,14 @@ -import { CliRenderEvents, TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core" -import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import { TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" import { Option, Schema } from "effect" -import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js" +import { createMemo, createSignal, Show } from "solid-js" import stripAnsi from "strip-ansi" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" import { Keymap } from "../../context/keymap" import { useTheme, useThemes } from "../../context/theme" -import { useDialog } from "../../ui/dialog" +import { dialogWidth, useDialog } from "../../ui/dialog" import { useToast } from "../../ui/toast" import { Locale } from "../../util/locale" import { getScrollAcceleration } from "../../util/scroll" @@ -24,11 +24,9 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const toast = useToast() const theme = useTheme("elevated") const { currentSyntax: syntax } = useThemes() - const renderer = useRenderer() const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal<"code" | "output">() - const [height, setHeight] = createSignal(1) const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height * 0.7) - 6)) let scroll: ScrollBoxRenderable | undefined // Unwrapped clips long lines and scrolls them itself. Each block clamps @@ -42,21 +40,6 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { blocks.forEach((block) => (block.scrollX = panX)) } - // Fit the scroll area to its content up to the cap. Wrapped code settles a - // frame after mount and output streams in, so grow from the measured height - // on every frame instead of measuring once; a resize restarts the fit. - createEffect(() => { - dimensions() - setHeight(1) - }) - const measure = () => { - if (!scroll) return - const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight)) - if (next > untrack(height)) setHeight(next) - } - renderer.on(CliRenderEvents.FRAME, measure) - onCleanup(() => renderer.off(CliRenderEvents.FRAME, measure)) - dialog.setSize("xlarge") dialog.setCentered(true) @@ -89,6 +72,19 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { if (Option.isNone(parsed)) return { json: "", rest: text } return { json, rest: text.slice(json.length).trim() } }) + // Code and JSON never wrap, so the content height is known up front. Sizing + // synchronously lets the dialog open complete instead of growing over frames. + const height = createMemo(() => { + const lines = (text: string) => (text ? text.split("\n").length : 1) + const width = Math.max(20, Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4) + const rest = sections().rest + ? sections() + .rest.split("\n") + .reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0) + : 0 + const outputRows = output() ? (sections().json ? lines(sections().json) : 0) + rest : 1 + return Math.min(maxHeight(), 1 + lines(code()) + 1 + 1 + outputRows) + }) const status = createMemo(() => { const state = props.part.state if (state.status === "streaming") return "Receiving code…" From b1a3123f625125fb1fd0f349af621e40a1c2dd55 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 15 Sep 2026 23:56:53 -0500 Subject: [PATCH 09/13] fix(tui): align execute dialog section titles with content column --- .../tui/src/routes/session/dialog-execute.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 48bf59d6bf73..6e320c03a79d 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -125,6 +125,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { // pad the block with fewer digits to keep code and output on one column. const digits = (text: string) => String(text ? text.split("\n").length : 0).length const pad = (own: string, other: string) => Math.max(0, digits(other) - digits(own)) + // Section titles sit over the first content character, past the shared gutter. + const indent = createMemo(() => Math.max(3, Math.max(digits(code()), digits(sections().json)) + 2)) return ( @@ -146,9 +148,11 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { > - - Code - + + + Code + + Waiting for code…}> @@ -166,9 +170,11 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { - - Output - + + + Output + + Date: Wed, 16 Sep 2026 00:01:03 -0500 Subject: [PATCH 10/13] fix(tui): align execute dialog titles with line numbers --- packages/tui/src/routes/session/dialog-execute.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 6e320c03a79d..80e75e88086f 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -125,8 +125,6 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { // pad the block with fewer digits to keep code and output on one column. const digits = (text: string) => String(text ? text.split("\n").length : 0).length const pad = (own: string, other: string) => Math.max(0, digits(other) - digits(own)) - // Section titles sit over the first content character, past the shared gutter. - const indent = createMemo(() => Math.max(3, Math.max(digits(code()), digits(sections().json)) + 2)) return ( @@ -148,7 +146,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { > - + Code @@ -170,7 +168,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { - + Output From 8d59f9eea660adf6222bafdfa3962fafa8a5cc25 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:26:50 -0500 Subject: [PATCH 11/13] fix(tui): align each execute dialog title with its own line numbers --- packages/tui/src/routes/session/dialog-execute.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 80e75e88086f..7dedeb5e5bf4 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -122,7 +122,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { })) // The gutter is digits + 2 wide and its minWidth is fixed at construction, so - // pad the block with fewer digits to keep code and output on one column. + // pad the block with fewer digits to keep code and output on one column. Each + // title then sits over the first digit of its own block's widest line number. const digits = (text: string) => String(text ? text.split("\n").length : 0).length const pad = (own: string, other: string) => Math.max(0, digits(other) - digits(own)) @@ -146,7 +147,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { > - + Code @@ -168,7 +169,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { - + Output From 8196134b7baedbaff15eb1cf387fd36aebc1f35b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:39:54 -0500 Subject: [PATCH 12/13] fix(tui): align execute dialog titles on a shared gutter line_number right-aligns digits in a gutter sized to that block alone, so the column "1" starts on changes with the line count and differs between calls. Draw one left-aligned gutter for both blocks instead. --- .../tui/src/routes/session/dialog-execute.tsx | 142 ++++++++++++------ 1 file changed, 92 insertions(+), 50 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 7dedeb5e5bf4..6ba75f01e9ad 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,4 +1,10 @@ -import { TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core" +import { + TextAttributes, + type CodeRenderable, + type RGBA, + type ScrollBoxRenderable, + type SyntaxStyle, +} from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" import { Option, Schema } from "effect" @@ -121,11 +127,20 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ], })) - // The gutter is digits + 2 wide and its minWidth is fixed at construction, so - // pad the block with fewer digits to keep code and output on one column. Each - // title then sits over the first digit of its own block's widest line number. - const digits = (text: string) => String(text ? text.split("\n").length : 0).length - const pad = (own: string, other: string) => Math.max(0, digits(other) - digits(own)) + // Both blocks share one gutter width so their content starts on the same + // column. The width only reserves digits; it does not shift the left edge. + const digits = createMemo(() => String(Math.max(lineCount(code()), lineCount(sections().json), 1)).length) + const source = (content: string, filetype: string) => ( + blocks.add(block)} + /> + ) return ( @@ -147,60 +162,41 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { > - - - Code - - + + Code + Waiting for code…}> - - - blocks.add(block)} - conceal={false} - wrapMode="none" - fg={theme.text.default} - filetype="typescript" - syntaxStyle={syntax()} - content={code()} - /> - - + {source(code(), "typescript")} - - - Output - - + + Output + - {props.part.state.status === "completed" ? "No output" : "Waiting for output…"} + + {output() + ? sections().rest + : props.part.state.status === "completed" + ? "No output" + : "Waiting for output…"} } > - - - - blocks.add(block)} - conceal={false} - wrapMode="none" - fg={theme.text.default} - filetype="json" - syntaxStyle={syntax()} - content={sections().json} - /> - - - + {source(sections().json, "json")} - - {sections().rest} - + + + {sections().rest} + + @@ -225,3 +221,49 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ) } + +function lineCount(text: string) { + return text ? text.split("\n").length : 0 +} + +// `` right-aligns digits in a gutter sized to that block alone, so +// the column "1" starts on depends on how many lines the block has. Padding a +// title to chase that column slides it between calls and never sits on the +// numbers actually on screen. A shared left-aligned gutter keeps the title and +// every line number on one column, in every call. +function NumberedSource(props: { + content: string + filetype: string + digits: number + fg: RGBA + muted: RGBA + syntax: SyntaxStyle + register: (block: CodeRenderable) => void +}) { + const gutter = createMemo(() => + props.content + .split("\n") + .map((_, index) => String(index + 1).padEnd(props.digits)) + .join("\n"), + ) + + return ( + + + {gutter()} + + + + + + ) +} From fbbd56db93f894867c26628ed9b6a0be4317b7aa Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:51:48 -0500 Subject: [PATCH 13/13] refactor(tui): type execute dialog output at the boundary Drop the prop-forwarding wrapper. Parse code and output from the tool state into string | undefined and a highlighted JSON result, and narrow those in Show instead of sentinel empty strings. --- .../tui/src/routes/session/dialog-execute.tsx | 174 ++++++++---------- 1 file changed, 80 insertions(+), 94 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 6ba75f01e9ad..89d50dbd6fbc 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,10 +1,4 @@ -import { - TextAttributes, - type CodeRenderable, - type RGBA, - type ScrollBoxRenderable, - type SyntaxStyle, -} from "@opentui/core" +import { TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import type { SessionMessageAssistantTool } from "@opencode/client/promise" import { Option, Schema } from "effect" @@ -18,7 +12,6 @@ import { dialogWidth, useDialog } from "../../ui/dialog" import { useToast } from "../../ui/toast" import { Locale } from "../../util/locale" import { getScrollAcceleration } from "../../util/scroll" -import { toolDisplayContent, toolDisplayMetadata } from "../../util/tool-display" const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) @@ -29,7 +22,6 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const clipboard = useClipboard() const toast = useToast() const theme = useTheme("elevated") - const { currentSyntax: syntax } = useThemes() const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal<"code" | "output">() @@ -49,47 +41,27 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { dialog.setSize("xlarge") dialog.setCentered(true) - const code = createMemo(() => { - const input = props.part.state.input - if (typeof input === "string") return "" - return typeof input.code === "string" ? input.code : "" + const code = createMemo(() => executeCode(props.part.state.input)) + const text = createMemo(() => outputText(props.part.state)) + const highlighted = createMemo(() => { + const value = text() + return value ? highlightedOutput(value) : undefined }) - const failed = createMemo( - () => toolDisplayMetadata(props.part.state).error === true || props.part.state.status === "error", - ) - const output = createMemo(() => { + const failed = createMemo(() => { const state = props.part.state - if (state.status === "error") return state.error.message - return stripAnsi( - toolDisplayContent(state) - .flatMap((item) => (item.type === "text" ? [item.text] : [])) - .join("\n") - .trim(), - ) - }) - // The tool prints a JSON result, optionally followed by "\n\nWarnings:" and - // "\n\nLogs:" sections. Highlight the JSON and keep any trailing text plain. - const sections = createMemo(() => { - const text = output() - if (!text.startsWith("{") && !text.startsWith("[")) return { json: "", rest: text } - const end = text.search(/\n\n(Warnings|Logs):\n/) - const json = end === -1 ? text : text.slice(0, end) - const parsed = decodeJson(json) - if (Option.isNone(parsed)) return { json: "", rest: text } - return { json, rest: text.slice(json.length).trim() } + if (state.status === "error") return true + if (state.status === "streaming") return false + return state.metadata?.error === true }) + // Both blocks share one gutter width so their content starts on the same column. + const digits = createMemo(() => String(Math.max(lineCount(code()), lineCount(highlighted()?.json), 1)).length) // Code and JSON never wrap, so the content height is known up front. Sizing // synchronously lets the dialog open complete instead of growing over frames. const height = createMemo(() => { - const lines = (text: string) => (text ? text.split("\n").length : 1) const width = Math.max(20, Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4) - const rest = sections().rest - ? sections() - .rest.split("\n") - .reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0) - : 0 - const outputRows = output() ? (sections().json ? lines(sections().json) : 0) + rest : 1 - return Math.min(maxHeight(), 1 + lines(code()) + 1 + 1 + outputRows) + const body = highlighted() + const outputRows = body ? lineCount(body.json) + wrappedRows(body.rest, width) : wrappedRows(text(), width) || 1 + return Math.min(maxHeight(), (lineCount(code()) || 1) + outputRows + 3) }) const status = createMemo(() => { const state = props.part.state @@ -103,10 +75,10 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { }) const copy = (kind: "code" | "output") => { - const text = kind === "code" ? code() : output() - if (!text) return + const value = kind === "code" ? code() : text() + if (!value) return void clipboard - .write(text) + .write(value) .then(() => setCopied(kind)) .catch(toast.error) } @@ -127,21 +99,6 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ], })) - // Both blocks share one gutter width so their content starts on the same - // column. The width only reserves digits; it does not shift the left edge. - const digits = createMemo(() => String(Math.max(lineCount(code()), lineCount(sections().json), 1)).length) - const source = (content: string, filetype: string) => ( - blocks.add(block)} - /> - ) - return ( @@ -166,7 +123,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { Code Waiting for code…}> - {source(code(), "typescript")} + {(value) => } @@ -174,30 +131,30 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { Output - {output() - ? sections().rest - : props.part.state.status === "completed" - ? "No output" - : "Waiting for output…"} + {text() ?? (props.part.state.status === "completed" ? "No output" : "Waiting for output…")} } > - {source(sections().json, "json")} - - - - {sections().rest} - - - + {(body) => ( + <> + + + {(rest) => ( + + + {rest()} + + + )} + + + )} @@ -222,24 +179,53 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ) } -function lineCount(text: string) { +function executeCode(input: SessionMessageAssistantTool["state"]["input"]) { + if (typeof input === "string") return + return typeof input.code === "string" && input.code ? input.code : undefined +} + +function outputText(state: SessionMessageAssistantTool["state"]) { + if (state.status === "error") return state.error.message || undefined + if (state.status !== "completed") return + const text = stripAnsi( + state.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .join("\n") + .trim(), + ) + return text || undefined +} + +// The tool prints a JSON result, optionally followed by "\n\nWarnings:" and "\n\nLogs:". +function highlightedOutput(text: string) { + if (!text.startsWith("{") && !text.startsWith("[")) return + const end = text.search(/\n\n(Warnings|Logs):\n/) + const json = end === -1 ? text : text.slice(0, end) + if (Option.isNone(decodeJson(json))) return + return { json, rest: text.slice(json.length).trim() || undefined } +} + +function lineCount(text: string | undefined) { return text ? text.split("\n").length : 0 } +function wrappedRows(text: string | undefined, width: number) { + if (!text) return 0 + return text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0) +} + // `` right-aligns digits in a gutter sized to that block alone, so -// the column "1" starts on depends on how many lines the block has. Padding a -// title to chase that column slides it between calls and never sits on the -// numbers actually on screen. A shared left-aligned gutter keeps the title and -// every line number on one column, in every call. -function NumberedSource(props: { +// the column "1" starts on depends on the line count and differs between calls. +// One shared left-aligned gutter keeps the title and every line number on the +// same column, and keeps code and output on one content column. +function GutteredCode(props: { content: string - filetype: string + filetype: "typescript" | "json" digits: number - fg: RGBA - muted: RGBA - syntax: SyntaxStyle - register: (block: CodeRenderable) => void + blocks: Set }) { + const theme = useTheme("elevated") + const syntax = useThemes().currentSyntax const gutter = createMemo(() => props.content .split("\n") @@ -249,18 +235,18 @@ function NumberedSource(props: { return ( - + {gutter()} props.blocks.add(block)} width="100%" conceal={false} wrapMode="none" - fg={props.fg} + fg={theme.text.default} filetype={props.filetype} - syntaxStyle={props.syntax} + syntaxStyle={syntax()} content={props.content} />