mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(tui): match execute dialog to two-toned dialog layout (#53584)
This commit is contained in:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user