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..89d50dbd6fbc --- /dev/null +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -0,0 +1,255 @@ +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 { 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 { dialogWidth, useDialog } from "../../ui/dialog" +import { useToast } from "../../ui/toast" +import { Locale } from "../../util/locale" +import { getScrollAcceleration } from "../../util/scroll" + +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 }) { + const dialog = useDialog() + const clipboard = useClipboard() + const toast = useToast() + const theme = useTheme("elevated") + const dimensions = useTerminalDimensions() + const config = useConfig().data + const [copied, setCopied] = createSignal<"code" | "output">() + 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 + // 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() + 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)) + } + + dialog.setSize("xlarge") + dialog.setCentered(true) + + 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(() => { + const state = props.part.state + 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 width = Math.max(20, Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4) + 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 + 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}` + }) + + const copy = (kind: "code" | "output") => { + const value = kind === "code" ? code() : text() + if (!value) return + void clipboard + .write(value) + .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(-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") }, + { 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…}> + {(value) => } + + + + + Output + + + {text() ?? (props.part.state.status === "completed" ? "No output" : "Waiting for output…")} + + } + > + {(body) => ( + <> + + + {(rest) => ( + + + {rest()} + + + )} + + + )} + + + + + + ↑/↓ ←/→ scroll + copy("code")}> + + {copied() === "code" ? "✓ copied" : "c"} + + {copied() === "code" ? "" : " copy code"} + + copy("output")}> + + {copied() === "output" ? "✓ copied" : "o"} + + {copied() === "output" ? "" : " copy output"} + + esc back + + + ) +} + +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 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: "typescript" | "json" + digits: number + blocks: Set +}) { + const theme = useTheme("elevated") + const syntax = useThemes().currentSyntax + const gutter = createMemo(() => + props.content + .split("\n") + .map((_, index) => String(index + 1).padEnd(props.digits)) + .join("\n"), + ) + + return ( + + + {gutter()} + + + props.blocks.add(block)} + width="100%" + conceal={false} + wrapMode="none" + fg={theme.text.default} + filetype={props.filetype} + syntaxStyle={syntax()} + content={props.content} + /> + + + ) +} 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"