fix(tui): match execute dialog to two-toned dialog layout (#53584)

This commit is contained in:
Dax
2026-10-06 15:20:21 -04:00
committed by GitHub
Unverified
parent c2f87853c6
commit ccb5162714
@@ -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 <code> 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<CodeRenderable>()
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 (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" gap={2}>
<box gap={1} paddingBottom={1}>
<box flexDirection="row" gap={2} paddingLeft={2} paddingRight={2}>
<text fg={theme.text.base} attributes={TextAttributes.BOLD} flexGrow={1}>
execute
</text>
@@ -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)}
>
<box gap={1}>
<box gap={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<box>
<text fg={theme.text.muted} attributes={TextAttributes.BOLD}>
<text fg={contentTheme.text.muted} attributes={TextAttributes.BOLD}>
Code
</text>
<Show when={code()} fallback={<text fg={theme.text.muted}>Waiting for code…</text>}>
{(value) => <GutteredCode content={value()} filetype="typescript" digits={digits()} blocks={blocks} />}
<Show when={code()} fallback={<text fg={contentTheme.text.muted}>Waiting for code…</text>}>
{(value) => <GutteredCode content={value()} filetype="typescript" digits={digits()} />}
</Show>
</box>
<box>
<text fg={theme.text.muted} attributes={TextAttributes.BOLD}>
<text fg={contentTheme.text.muted} attributes={TextAttributes.BOLD}>
Output
</text>
<Show
when={highlighted()}
fallback={
<text
fg={text() ? (failed() ? theme.text.feedback.error.base : theme.text.base) : theme.text.muted}
fg={
text()
? failed()
? contentTheme.text.feedback.error.base
: contentTheme.text.base
: contentTheme.text.muted
}
wrapMode="word"
>
{text() ?? (props.part.state.status === "completed" ? "No output" : "Waiting for output…")}
@@ -143,11 +143,14 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
>
{(body) => (
<>
<GutteredCode content={body().json} filetype="json" digits={digits()} blocks={blocks} />
<GutteredCode content={body().json} filetype="json" digits={digits()} />
<Show when={body().rest}>
{(rest) => (
<box paddingLeft={digits() + 1}>
<text fg={failed() ? theme.text.feedback.error.base : theme.text.base} wrapMode="word">
<text
fg={failed() ? contentTheme.text.feedback.error.base : contentTheme.text.base}
wrapMode="word"
>
{rest()}
</text>
</box>
@@ -159,8 +162,8 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
</box>
</box>
</scrollbox>
<box flexDirection="row" gap={3} flexWrap="wrap">
<text fg={theme.text.muted}>↑/↓ ←/→ scroll</text>
<box flexDirection="row" gap={3} flexWrap="wrap" paddingLeft={2} paddingRight={2}>
<text fg={theme.text.muted}>↑/↓ scroll</text>
<text onMouseUp={() => copy("code")}>
<span style={{ fg: copied() === "code" ? theme.text.feedback.success.base : theme.text.base }}>
<b>{copied() === "code" ? "✓ copied" : "c"}</b>
@@ -217,21 +220,23 @@ function wrappedRows(text: string | undefined, width: number) {
// `<line_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<CodeRenderable>
}) {
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<LineInfo>()
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 (
<box flexDirection="row" gap={1} width="100%">
@@ -240,10 +245,10 @@ function GutteredCode(props: {
</text>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<code
ref={(block: CodeRenderable) => props.blocks.add(block)}
ref={track}
width="100%"
conceal={false}
wrapMode="none"
wrapMode="char"
fg={theme.text.base}
filetype={props.filetype}
syntaxStyle={syntax()}