From 8b0176e80db2e74a931307704d1b031be9c3e01d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 6 Oct 2026 15:08:31 -0400 Subject: [PATCH] fix(tui): match execute dialog to two-toned dialog layout --- .../tui/src/routes/session/dialog-execute.tsx | 97 ++++++++++--------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index a72c8cfcb2bc..9155da4d1be3 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -1,8 +1,8 @@ -import { TextAttributes, type CodeRenderable, type ScrollBoxRenderable } from "@opentui/core" +import { TextAttributes, type CodeRenderable, type LineInfo, 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 { createMemo, createSignal, onCleanup, Show } from "solid-js" import stripAnsi from "strip-ansi" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" @@ -22,21 +22,12 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const clipboard = useClipboard() const toast = useToast() const theme = useTheme().surface("dialog") + const contentTheme = useTheme() 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) @@ -55,13 +46,16 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { }) // 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. + // Code and JSON wrap by character, 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 codeWidth = Math.max(1, width - digits() - 1) 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 outputRows = body + ? wrappedRows(body.json, codeWidth) + wrappedRows(body.rest, codeWidth) + : wrappedRows(text(), width) || 1 + return Math.min(maxHeight(), (wrappedRows(code(), codeWidth) || 1) + outputRows + 5) }) const status = createMemo(() => { const state = props.part.state @@ -90,8 +84,6 @@ 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") }, @@ -100,8 +92,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { })) return ( - - + + execute @@ -114,27 +106,35 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { id="execute-detail-scroll" ref={(value: ScrollBoxRenderable) => (scroll = value)} height={height()} + contentOptions={{ minHeight: 0 }} + backgroundColor={contentTheme.background.raised.high} scrollbarOptions={{ visible: false }} scrollAcceleration={getScrollAcceleration(config)} > - + - + Code - Waiting for code…}> - {(value) => } + Waiting for code…}> + {(value) => } - + Output {text() ?? (props.part.state.status === "completed" ? "No output" : "Waiting for output…")} @@ -143,11 +143,14 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { > {(body) => ( <> - + {(rest) => ( - + {rest()} @@ -159,8 +162,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { - - ↑/↓ ←/→ scroll + + ↑/↓ scroll copy("code")}> {copied() === "code" ? "✓ copied" : "c"} @@ -217,21 +220,23 @@ function wrappedRows(text: string | undefined, width: number) { // `` 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().surface("dialog") +// same column, and keeps code and output on one content column. The gutter +// follows the wrapped layout so continuation rows stay unnumbered. +function GutteredCode(props: { content: string; filetype: "typescript" | "json"; digits: number }) { + const theme = useTheme() const syntax = useThemes().currentSyntax - const gutter = createMemo(() => - props.content - .split("\n") - .map((_, index) => String(index + 1).padEnd(props.digits)) - .join("\n"), - ) + const [info, setInfo] = createSignal() + const gutter = createMemo(() => { + const sources = info()?.lineSources ?? props.content.split("\n").map((_, index) => index) + return sources + .map((source, index) => (sources[index - 1] === source ? "" : String(source + 1)).padEnd(props.digits)) + .join("\n") + }) + const track = (block: CodeRenderable) => { + const update = () => setInfo({ ...block.lineInfo }) + block.on("line-info-change", update) + onCleanup(() => block.off("line-info-change", update)) + } return ( @@ -240,10 +245,10 @@ function GutteredCode(props: { props.blocks.add(block)} + ref={track} width="100%" conceal={false} - wrapMode="none" + wrapMode="char" fg={theme.text.base} filetype={props.filetype} syntaxStyle={syntax()}