Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 51 additions & 46 deletions packages/tui/src/routes/session/dialog-execute.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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") },
Expand All @@ -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>
Expand All @@ -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โ€ฆ")}
Expand All @@ -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>
Expand All @@ -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>
Expand Down Expand Up @@ -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%">
Expand All @@ -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()}
Expand Down
Loading