Skip to content
Open
Show file tree
Hide file tree
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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})

export const ShowToolOutput = Schema.Array(Schema.String).annotate({
description: "Tool names whose output should always be shown in the TUI",
})

export const Attention = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
Expand All @@ -74,5 +78,6 @@ export const TuiInfo = Schema.Struct({
}),
scroll_acceleration: Schema.optional(ScrollAcceleration),
diff_style: Schema.optional(DiffStyle),
show_tool_output: Schema.optional(ShowToolOutput),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
})
254 changes: 245 additions & 9 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createSignal,
For,
Match,
onCleanup,
on,
onMount,
Show,
Expand Down Expand Up @@ -90,6 +91,17 @@ import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
import {
clearTerminalImageOutput,
hasTerminalImageOutput,
supportsTerminalImageOutput,
terminalImageSector,
terminalImageSectorFromOutput,
terminalImagePath,
terminalImageSizeFromFile,
writeTerminalImageFileOutput,
writeTerminalImageOutput,
} from "../../util/terminal-image"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -153,8 +165,12 @@ const sessionBindingCommands = [
"session.child.previous",
] as const

const fallbackTerminalImageRows = 12

const context = createContext<{
width: number
height: number
scrollViewport: { y: number; height: number } | undefined
sessionID: string
conceal: () => boolean
showThinking: () => boolean
Expand Down Expand Up @@ -1084,6 +1100,13 @@ export function Session() {
get width() {
return contentWidth()
},
get height() {
return dimensions().height
},
get scrollViewport() {
if (!scroll?.viewport) return
return { y: scroll.viewport.screenY, height: scroll.viewport.height }
},
sessionID: route.sessionID,
conceal,
showThinking,
Expand Down Expand Up @@ -1639,34 +1662,247 @@ type ToolProps<T> = {
function GenericTool(props: ToolProps<any>) {
const { theme } = useTheme()
const ctx = use()
const renderer = useRenderer()
const [imageBox, setImageBox] = createSignal<BoxRenderable>()
const [fileImageSector, setFileImageSector] = createSignal<{ columns: number; rows: number }>()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const hasTerminalOutput = createMemo(() => hasTerminalImageOutput(output()))
const imagePath = createMemo(() => (props.tool === "show_image" ? terminalImagePath(props.input) : undefined))
const configuredOutput = createMemo(() => ctx.tui.show_tool_output?.includes(props.tool) ?? false)
const shouldRenderOutput = createMemo(
() => props.output && (ctx.showGenericToolOutput() || configuredOutput() || imagePath()),
)
const terminalOutputSupported = createMemo(() => supportsTerminalImageOutput())
const shouldRenderImageArea = createMemo(() => Boolean((hasTerminalOutput() || imagePath()) && terminalOutputSupported()))
const inlineImageMaxRows = createMemo(() => Math.max(1, Math.floor(ctx.height / 2)))
const inlineImageSector = createMemo(() => {
const width = Math.max(1, Math.floor(ctx.width))
return terminalImageSectorFromOutput(output(), { maxWidth: width, maxHeight: inlineImageMaxRows() }) ?? fileImageSector() ?? {
columns: width,
rows: Math.min(fallbackTerminalImageRows, inlineImageMaxRows()),
}
})
const [expanded, setExpanded] = createSignal<boolean | undefined>()
const defaultExpanded = createMemo(() => configuredOutput() || Boolean(imagePath()))
const isExpanded = createMemo(() => expanded() ?? defaultExpanded())
const title = createMemo(() => {
if ("title" in props.part.state && props.part.state.title) return props.part.state.title
return `${props.tool} ${input(props.input)}`
})
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
if (isExpanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
})
const visible = createMemo(() => {
if (!hasTerminalOutput() && !imagePath()) return limited()
if (!terminalOutputSupported()) return "Inline image output requires an iTerm2-compatible terminal"
return stripAnsi(limited()).trim() || "Displayed inline image"
})
const canToggleExpanded = createMemo(() => overflow() || shouldRenderImageArea())
const shouldShowInlineImage = createMemo(() => shouldRenderImageArea() && isExpanded())
let pendingInlineImageWrite: Promise<void> | undefined
let renderedInlineImageKey: string | undefined
let renderedInlineImagePlacement: { x: number; y: number; width: number; height: number } | undefined
let pendingInlineImageClear: { x: number; y: number; width: number; height: number } | undefined
let skipNextFrameImageWrite = false

const inlineImagePlacement = () => {
const box = imageBox()
if (!box) return
if (!props.output) return
if (!shouldShowInlineImage()) return
const viewport = ctx.scrollViewport
if (!viewport) return
const y = Math.floor(box.screenY)
const viewportTop = Math.floor(viewport.y)
const viewportBottom = Math.floor(viewport.y + viewport.height)
const height = inlineImageSector().rows
if (y < viewportTop) return
if (Math.floor(box.height) < height) return
if (y + height > viewportBottom) return
return {
x: box.screenX,
y,
width: Math.min(Math.max(1, Math.floor(box.width)), inlineImageSector().columns),
height,
}
}

const shouldCollapseInlineImage = () => {
const box = imageBox()
if (!box) return false
if (!props.output) return false
if (!shouldShowInlineImage()) return false
const viewport = ctx.scrollViewport
if (!viewport) return false
const y = Math.floor(box.screenY)
const height = inlineImageSector().rows
return (
y < Math.floor(viewport.y) ||
Math.floor(box.height) < height ||
y + height > Math.floor(viewport.y + viewport.height)
)
}

const inlineImageKey = () => {
const raw = props.output
if (!raw) return
const placement = inlineImagePlacement()
if (!placement) return
return [
props.part.callID,
raw.length,
imagePath() ?? "",
Math.floor(placement.x),
Math.floor(placement.y),
placement.width,
placement.height,
].join(":")
}

const writeInlineImage = async () => {
if (!props.output) return
if (!hasTerminalOutput() && !imagePath()) return
if (!terminalOutputSupported()) return
if (!shouldShowInlineImage()) return
if (!ctx.showGenericToolOutput() && !configuredOutput() && !imagePath()) return
const placement = inlineImagePlacement()
if (!placement) return
renderedInlineImagePlacement = placement
const raw = props.output
const filePath = imagePath()
if (hasTerminalImageOutput(raw)) {
await writeTerminalImageOutput(raw, { placement })
return
}
if (filePath) {
await writeTerminalImageFileOutput(filePath, {
display: { width: placement.width, height: placement.height, preserveAspectRatio: true },
placement,
})
}
}

const clearRenderedInlineImage = async (options: { requestRender?: boolean } = {}) => {
const placements = [renderedInlineImagePlacement, pendingInlineImageClear].filter(
(placement): placement is { x: number; y: number; width: number; height: number } => Boolean(placement),
)
if (!placements.length) return
renderedInlineImagePlacement = undefined
pendingInlineImageClear = undefined
renderedInlineImageKey = undefined
await Promise.all(placements.map((placement) => clearTerminalImageOutput(placement)))
if (options.requestRender === false) return
skipNextFrameImageWrite = true
renderer.requestRender()
}

const scheduleInlineImageWrite = () => {
if (pendingInlineImageWrite) return
pendingInlineImageWrite = new Promise<void>((resolve) => setTimeout(resolve, 25))
.then(async () => {
const key = inlineImageKey()
if (!key) return
if (key === renderedInlineImageKey) return
if (renderedInlineImagePlacement) {
pendingInlineImageClear = renderedInlineImagePlacement
renderedInlineImagePlacement = undefined
renderedInlineImageKey = undefined
renderer.requestRender()
return
}
renderedInlineImageKey = key
await writeInlineImage()
skipNextFrameImageWrite = true
renderer.requestRender()
})
.finally(() => {
pendingInlineImageWrite = undefined
})
}

createEffect(() => {
const filePath = imagePath()
const maxWidth = Math.max(1, Math.floor(ctx.width))
const maxHeight = inlineImageMaxRows()
if (!filePath) {
setFileImageSector(undefined)
return
}
void terminalImageSizeFromFile(filePath).then((size) => {
if (imagePath() !== filePath) return
if (Math.max(1, Math.floor(ctx.width)) !== maxWidth) return
if (inlineImageMaxRows() !== maxHeight) return
setFileImageSector(size ? terminalImageSector(size, { maxWidth, maxHeight }) : undefined)
})
})

createEffect(() => {
if (!shouldShowInlineImage()) return
if (!imageBox()) return
output()
inlineImageSector()
renderer.requestRender()
void renderer.idle().then(() => scheduleInlineImageWrite())
})

const frameCallback = async () => {
if (!shouldShowInlineImage()) return
if (skipNextFrameImageWrite) {
skipNextFrameImageWrite = false
return
}
if (shouldCollapseInlineImage() && renderedInlineImagePlacement) {
setExpanded(false)
await clearRenderedInlineImage()
return
}
if (!inlineImagePlacement() && renderedInlineImagePlacement) {
await clearRenderedInlineImage({ requestRender: false })
return
}
if (pendingInlineImageClear) {
const placement = pendingInlineImageClear
pendingInlineImageClear = undefined
await clearTerminalImageOutput(placement)
}
void renderer.idle().then(() => scheduleInlineImageWrite())
}
renderer.setFrameCallback(frameCallback)
onCleanup(() => {
renderer.removeFrameCallback(frameCallback)
void clearRenderedInlineImage()
})

createEffect(() => {
if (shouldShowInlineImage()) return
void clearRenderedInlineImage()
})

return (
<Show
when={props.output && ctx.showGenericToolOutput()}
when={shouldRenderOutput()}
fallback={
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
{props.tool} {input(props.input)}
{title()}
</InlineTool>
}
>
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
title={`# ${title()}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={canToggleExpanded() ? () => setExpanded((prev) => !(prev ?? defaultExpanded())) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
<text fg={theme.text}>{visible()}</text>
<Show when={shouldShowInlineImage()}>
<box ref={(box: BoxRenderable) => setImageBox(box)} height={inlineImageSector().rows} flexShrink={0} />
</Show>
<Show when={canToggleExpanded()}>
<text fg={theme.textMuted}>{isExpanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
Expand Down
Loading
Loading