From 0011f07e32a950f4228b8e231e4feda918ba6e12 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Tue, 28 Apr 2026 15:47:57 +0900 Subject: [PATCH 1/3] feat(tui): support configured generic tool output Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/cli/cmd/tui/config/tui-schema.ts | 5 +++++ .../src/cli/cmd/tui/routes/session/index.tsx | 20 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 2c99f2a5ef61..9919ac3d15c1 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -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), @@ -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)" }), }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b5e8e10283e3..a078502bd74a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1640,33 +1640,39 @@ function GenericTool(props: ToolProps) { const { theme } = useTheme() const ctx = use() const output = createMemo(() => props.output?.trim() ?? "") - const [expanded, setExpanded] = createSignal(false) + const configuredOutput = createMemo(() => ctx.tui.show_tool_output?.includes(props.tool) ?? false) + const [expanded, setExpanded] = createSignal() + const isExpanded = createMemo(() => expanded() ?? configuredOutput()) + 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") }) return ( - {props.tool} {input(props.input)} + {title()} } > setExpanded((prev) => !prev) : undefined} + onClick={overflow() ? () => setExpanded((prev) => !(prev ?? configuredOutput())) : undefined} > {limited()} - {expanded() ? "Click to collapse" : "Click to expand"} + {isExpanded() ? "Click to collapse" : "Click to expand"} From 19e7cbd2460734f5fabc9642eb591378e2d77086 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Thu, 14 May 2026 17:14:19 +0900 Subject: [PATCH 2/3] fix(tui): render terminal images inline Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bun.lock | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 138 +++++++++- .../src/cli/cmd/tui/util/terminal-image.ts | 251 ++++++++++++++++++ .../test/cli/tui/terminal-image.test.ts | 187 +++++++++++++ 4 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/terminal-image.ts create mode 100644 packages/opencode/test/cli/tui/terminal-image.test.ts diff --git a/bun.lock b/bun.lock index 3fafe9d5e02d..b7937e05e193 100644 --- a/bun.lock +++ b/bun.lock @@ -2168,7 +2168,7 @@ "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], - "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }, "sha512-7JjjA49VGNOsMRI8QRUhVudZmv0CnJ18SliSgK1ojszs/c3ijftgVkzvXdkSLN4miDTzbkXewf65D6ZBo6W+GQ=="], + "@solidjs/start": ["@solidjs/start@https://pkg.pr.new/@solidjs/start@dfb2020", { "dependencies": { "@babel/core": "^7.28.3", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.5", "@solidjs/meta": "^0.29.4", "@tanstack/server-functions-plugin": "1.134.5", "@types/babel__traverse": "^7.28.0", "@types/micromatch": "^4.0.9", "cookie-es": "^2.0.0", "defu": "^6.1.4", "error-stack-parser": "^2.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "fast-glob": "^3.3.3", "h3": "npm:h3@2.0.1-rc.4", "html-to-image": "^1.11.13", "micromatch": "^4.0.8", "path-to-regexp": "^8.2.0", "pathe": "^2.0.3", "radix3": "^1.1.2", "seroval": "^1.3.2", "seroval-plugins": "^1.2.1", "shiki": "^1.26.1", "solid-js": "^1.9.9", "source-map-js": "^1.2.1", "srvx": "^0.9.1", "terracotta": "^1.0.6", "vite": "7.1.10", "vite-plugin-solid": "^2.11.9", "vitest": "^4.0.10" } }], "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], @@ -3266,7 +3266,7 @@ "get-tsconfig": ["get-tsconfig@4.13.8", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-J87BxkLXykmisLQ+KA4x2+O6rVf+PJrtFUO8lGyiRg4lyxJLJ8/v0sRAKdVZQOy6tR6lMRAF1NqzCf9BQijm0w=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361"], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index a078502bd74a..9e8494f22d18 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -6,6 +6,7 @@ import { createSignal, For, Match, + onCleanup, on, onMount, Show, @@ -90,6 +91,16 @@ 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 { + hasTerminalImageOutput, + supportsTerminalImageOutput, + terminalImageSector, + terminalImageSectorFromOutput, + terminalImagePath, + terminalImageSizeFromFile, + writeTerminalImageFileOutput, + writeTerminalImageOutput, +} from "../../util/terminal-image" addDefaultParsers(parsers.parsers) @@ -153,8 +164,11 @@ const sessionBindingCommands = [ "session.child.previous", ] as const +const fallbackTerminalImageRows = 12 + const context = createContext<{ width: number + height: number sessionID: string conceal: () => boolean showThinking: () => boolean @@ -1084,6 +1098,9 @@ export function Session() { get width() { return contentWidth() }, + get height() { + return dimensions().height + }, sessionID: route.sessionID, conceal, showThinking, @@ -1639,8 +1656,26 @@ type ToolProps = { function GenericTool(props: ToolProps) { const { theme } = useTheme() const ctx = use() + const renderer = useRenderer() + const [imageBox, setImageBox] = createSignal() + const [fileImageSector, setFileImageSector] = createSignal<{ columns: number; rows: number }>() const output = createMemo(() => props.output?.trim() ?? "") + 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 * 0.45))) + 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() const isExpanded = createMemo(() => expanded() ?? configuredOutput()) const title = createMemo(() => { @@ -1654,10 +1689,106 @@ function GenericTool(props: ToolProps) { 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" + }) + let pendingInlineImageWrite: Promise | undefined + let renderedInlineImageKey: string | undefined + let lastInlineImageWriteAt = 0 + + const inlineImageKey = () => { + const box = imageBox() + if (!box) return + if (!props.output) return + if (!shouldRenderImageArea()) return + return [ + props.part.callID, + props.output.length, + imagePath() ?? "", + Math.floor(box.screenX), + Math.floor(box.screenY), + inlineImageSector().columns, + Math.min(inlineImageSector().rows, Math.max(1, Math.floor(ctx.height - box.screenY - 2))), + ].join(":") + } + + const writeInlineImage = async () => { + if (!props.output) return + if (!hasTerminalOutput() && !imagePath()) return + if (!terminalOutputSupported()) return + if (!ctx.showGenericToolOutput() && !configuredOutput() && !imagePath()) return + const box = imageBox() + if (!box) return + const width = Math.min(Math.max(1, Math.floor(box.width)), inlineImageSector().columns) + const height = Math.min(inlineImageSector().rows, Math.max(1, Math.floor(ctx.height - box.screenY - 2))) + const raw = props.output + const filePath = imagePath() + if (hasTerminalImageOutput(raw)) { + await writeTerminalImageOutput(raw, { placement: { x: box.screenX, y: box.screenY, width, height } }) + return + } + if (filePath) { + await writeTerminalImageFileOutput(filePath, { + display: { width, height, preserveAspectRatio: true }, + placement: { x: box.screenX, y: box.screenY, width, height }, + }) + } + } + + const scheduleInlineImageWrite = (force = false) => { + if (pendingInlineImageWrite) return + const delay = force ? Math.max(0, 250 - (Date.now() - lastInlineImageWriteAt)) : 25 + pendingInlineImageWrite = new Promise((resolve) => setTimeout(resolve, delay)) + .then(async () => { + const key = inlineImageKey() + if (!key) return + if (!force && key === renderedInlineImageKey) return + renderedInlineImageKey = key + await writeInlineImage() + lastInlineImageWriteAt = Date.now() + }) + .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 (!shouldRenderImageArea()) return + if (!imageBox()) return + output() + inlineImageSector() + renderer.requestRender() + void renderer.idle().then(() => scheduleInlineImageWrite()) + }) + + const frameCallback = async () => { + if (!shouldRenderImageArea()) return + void renderer.idle().then(() => scheduleInlineImageWrite(true)) + } + renderer.setFrameCallback(frameCallback) + onCleanup(() => renderer.removeFrameCallback(frameCallback)) return ( {title()} @@ -1670,7 +1801,10 @@ function GenericTool(props: ToolProps) { onClick={overflow() ? () => setExpanded((prev) => !(prev ?? configuredOutput())) : undefined} > - {limited()} + {visible()} + + setImageBox(box)} height={inlineImageSector().rows} flexShrink={0} /> + {isExpanded() ? "Click to collapse" : "Click to expand"} diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts b/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts new file mode 100644 index 000000000000..ea5348ed20bd --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts @@ -0,0 +1,251 @@ +import { writeFileSync } from "node:fs" +import path from "node:path" + +export const TerminalImageOSC = /\x1b\]1337;File=/ + +const imageMime = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", +} satisfies Record + +let terminalImageWriteQueue = Promise.resolve() + +export type TerminalImageDisplayOptions = { + width?: number + height?: number + preserveAspectRatio?: boolean + doNotMoveCursor?: boolean +} + +export type TerminalImagePlacementOptions = { + x: number + y: number + width: number + height: number +} + +export type TerminalImageSize = { + width: number + height: number +} + +export type TerminalImageSector = { + columns: number + rows: number +} + +export function hasTerminalImageOutput(output: string) { + return TerminalImageOSC.test(output) +} + +export function supportsTerminalImageOutput( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +) { + if (platform === "win32") return false + if (env.TMUX) return false + if (env.ITERM_SESSION_ID) return true + if (env.LC_TERMINAL?.toLowerCase() === "iterm2") return true + if (env.TERM_PROGRAM?.toLowerCase() === "iterm.app") return supportsITermImageVersion(env.TERM_PROGRAM_VERSION) + return false +} + +export async function writeTerminalImageOutput( + raw: string, + options: { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + write?: (raw: string) => Promise + placement?: TerminalImagePlacementOptions + } = {}, +) { + if (!hasTerminalImageOutput(raw)) return false + if (!supportsTerminalImageOutput(options.env, options.platform)) return false + await writeQueued(options.placement ? terminalImagePlacementOutput(raw, options.placement) : raw, options.write) + return true +} + +export async function writeTerminalImageFileOutput( + filePath: string, + options: { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + write?: (raw: string) => Promise + display?: TerminalImageDisplayOptions + placement?: TerminalImagePlacementOptions + } = {}, +) { + if (!supportsTerminalImageOutput(options.env, options.platform)) return false + const raw = await terminalImageOutputFromFile(filePath, options.display) + if (!raw) return false + await writeQueued(options.placement ? terminalImagePlacementOutput(raw, options.placement) : raw, options.write) + return true +} + +export async function terminalImageOutputFromFile(filePath: string, options: TerminalImageDisplayOptions = {}) { + const mime = imageMime[path.extname(filePath).toLowerCase().slice(1) as keyof typeof imageMime] + if (!mime) return + const file = Bun.file(filePath) + if (!(await file.exists())) return + return `\x1b]1337;File=${terminalImageParams(path.basename(filePath), options)}:${Buffer.from(await file.arrayBuffer()).toString("base64")}\x07` +} + +export async function terminalImageSizeFromFile(filePath: string) { + const mime = imageMime[path.extname(filePath).toLowerCase().slice(1) as keyof typeof imageMime] + if (!mime) return + const file = Bun.file(filePath) + if (!(await file.exists())) return + return terminalImageSize(Buffer.from(await file.arrayBuffer()), path.extname(filePath).toLowerCase().slice(1)) +} + +export function terminalImageSector( + size: TerminalImageSize, + options: { maxWidth: number; maxHeight?: number; cellWidth?: number; cellHeight?: number }, +) { + const cellWidth = options.cellWidth ?? 9 + const cellHeight = options.cellHeight ?? 18 + const imageWidth = Math.max(1, size.width) + const imageHeight = Math.max(1, size.height) + const maxWidth = Math.max(1, Math.floor(options.maxWidth)) + const maxHeight = Math.max(1, Math.floor(options.maxHeight ?? Number.POSITIVE_INFINITY)) + const columns = Math.min(maxWidth, Math.max(1, Math.ceil(imageWidth / cellWidth))) + const rows = Math.max(1, Math.ceil((imageHeight * ((columns * cellWidth) / imageWidth)) / cellHeight)) + if (rows <= maxHeight) return { columns, rows } + const heightBoundColumns = Math.max(1, Math.ceil((imageWidth * ((maxHeight * cellHeight) / imageHeight)) / cellWidth)) + return { + columns: Math.min(maxWidth, heightBoundColumns), + rows: maxHeight, + } +} + +export function terminalImageSectorFromOutput(raw: string, options: { maxWidth: number; maxHeight?: number }) { + const params = raw.match(/\x1b\]1337;File=([^:]*):/)?.[1] + if (!params) return + const width = terminalImageCellParam(params, "width") + const height = terminalImageCellParam(params, "height") + if (!height) return + const maxHeight = Math.max(1, Math.floor(options.maxHeight ?? Number.POSITIVE_INFINITY)) + return { + columns: Math.min(Math.max(1, Math.floor(options.maxWidth)), width ?? Math.max(1, Math.floor(options.maxWidth))), + rows: Math.min(height, maxHeight), + } +} + +export function terminalImagePlacementOutput(raw: string, options: TerminalImagePlacementOptions) { + const x = Math.max(1, Math.floor(options.x) + 1) + const y = Math.max(1, Math.floor(options.y) + 1) + const width = Math.max(1, Math.floor(options.width)) + const height = Math.max(1, Math.floor(options.height)) + const clear = Array.from({ length: height }, (_, row) => `\x1b[${y + row};${x}H\x1b[${width}X`).join("") + return `\x1b7${clear}\x1b[${y};${x}H${raw}\x1b8` +} + +export function terminalImagePath(input: Record | undefined) { + const value = input?.path ?? input?.filePath ?? input?.file_path + if (typeof value !== "string") return + return value +} + +function supportsITermImageVersion(version: string | undefined) { + if (!version) return true + const parsed = version + .split(/\D+/) + .filter(Boolean) + .map((part) => Number(part)) + if (parsed.some((part) => Number.isNaN(part))) return true + const [major = 0, minor = 0, patch = 0] = parsed + if (major > 2) return true + if (major < 2) return false + if (minor > 9) return true + if (minor < 9) return false + return patch >= 20150512 +} + +function terminalImageParams(name: string, options: TerminalImageDisplayOptions) { + return [ + `name=${Buffer.from(name).toString("base64")}`, + "inline=1", + `doNotMoveCursor=${options.doNotMoveCursor === false ? 0 : 1}`, + options.preserveAspectRatio === undefined ? undefined : `preserveAspectRatio=${options.preserveAspectRatio ? 1 : 0}`, + terminalImageDimension("width", options.width), + terminalImageDimension("height", options.height), + ] + .filter((param): param is string => Boolean(param)) + .join(";") +} + +function terminalImageDimension(name: "width" | "height", value: number | undefined) { + if (!value) return + return `${name}=${Math.max(1, Math.floor(value))}` +} + +function terminalImageCellParam(params: string, name: "width" | "height") { + const value = params + .split(";") + .find((param) => param.startsWith(`${name}=`)) + ?.slice(name.length + 1) + if (!value?.match(/^\d+$/)) return + return Math.max(1, Number(value)) +} + +function terminalImageSize(buffer: Buffer, extension: string): TerminalImageSize | undefined { + if (extension === "png") return pngSize(buffer) + if (extension === "gif") return gifSize(buffer) + if (extension === "jpg" || extension === "jpeg") return jpegSize(buffer) + if (extension === "webp") return webpSize(buffer) +} + +function pngSize(buffer: Buffer): TerminalImageSize | undefined { + if (buffer.length < 24) return + if (buffer.toString("hex", 0, 8) !== "89504e470d0a1a0a") return + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) } +} + +function gifSize(buffer: Buffer): TerminalImageSize | undefined { + if (buffer.length < 10) return + if (!buffer.toString("ascii", 0, 6).match(/^GIF8[79]a$/)) return + return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) } +} + +function jpegSize(buffer: Buffer): TerminalImageSize | undefined { + if (buffer.length < 4) return + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return + const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) + let offset = 2 + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset++ + continue + } + const marker = buffer[offset + 1] + const length = buffer.readUInt16BE(offset + 2) + if (sof.has(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) } + offset += 2 + length + } +} + +function webpSize(buffer: Buffer): TerminalImageSize | undefined { + if (buffer.length < 30) return + if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WEBP") return + if (buffer.toString("ascii", 12, 16) !== "VP8X") return + return { + width: buffer.readUIntLE(24, 3) + 1, + height: buffer.readUIntLE(27, 3) + 1, + } +} + +async function writeToControllingTerminal(raw: string) { + try { + writeFileSync("/dev/tty", raw) + } catch { + // Best-effort rendering: terminal image output is optional and should never break the TUI. + } +} + +async function writeQueued(raw: string, write = writeToControllingTerminal) { + terminalImageWriteQueue = terminalImageWriteQueue.then(() => write(raw), () => write(raw)) + await terminalImageWriteQueue +} diff --git a/packages/opencode/test/cli/tui/terminal-image.test.ts b/packages/opencode/test/cli/tui/terminal-image.test.ts new file mode 100644 index 000000000000..8b59687a20b3 --- /dev/null +++ b/packages/opencode/test/cli/tui/terminal-image.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test" +import { + hasTerminalImageOutput, + supportsTerminalImageOutput, + terminalImageOutputFromFile, + terminalImagePlacementOutput, + terminalImagePath, + terminalImageSector, + terminalImageSectorFromOutput, + terminalImageSizeFromFile, + writeTerminalImageFileOutput, + writeTerminalImageOutput, +} from "@/cli/cmd/tui/util/terminal-image" +import { tmpdir } from "../../fixture/fixture" + +const image = "\x1b]1337;File=name=test.png;inline=1:aW1hZ2U=\x07" + +describe("terminal image output", () => { + test("detects iTerm2 OSC image payloads", () => { + expect(hasTerminalImageOutput(image)).toBe(true) + expect(hasTerminalImageOutput("plain text output")).toBe(false) + }) + + test("supports modern iTerm2 sessions", () => { + expect(supportsTerminalImageOutput({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.5.0" }, "darwin")).toBe( + true, + ) + expect(supportsTerminalImageOutput({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "2.9.20150512" }, "darwin")) + .toBe(true) + expect(supportsTerminalImageOutput({ ITERM_SESSION_ID: "w0t0p0:20260514_120000" }, "darwin")).toBe(true) + expect(supportsTerminalImageOutput({ LC_TERMINAL: "iTerm2" }, "linux")).toBe(true) + }) + + test("rejects terminals without known iTerm2 OSC support", () => { + expect(supportsTerminalImageOutput({ TERM_PROGRAM: "Apple_Terminal", TERM: "xterm-256color" }, "darwin")).toBe( + false, + ) + expect(supportsTerminalImageOutput({ TERM: "xterm-256color", COLORTERM: "truecolor" }, "linux")).toBe(false) + expect(supportsTerminalImageOutput({ TERM_PROGRAM: "iTerm.app" }, "win32")).toBe(false) + }) + + test("rejects iTerm2 versions before inline images shipped", () => { + expect(supportsTerminalImageOutput({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "2.9.20150511" }, "darwin")) + .toBe(false) + expect(supportsTerminalImageOutput({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "2.8.9" }, "darwin")).toBe( + false, + ) + }) + + test("does not assume tmux passes OSC image payloads through", () => { + expect( + supportsTerminalImageOutput( + { TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.5.0", TMUX: "/tmp/tmux-501/default,1,0" }, + "darwin", + ), + ).toBe(false) + }) + + test("only writes OSC images for supported terminal environments", async () => { + const writes: string[] = [] + const write = async (raw: string) => { + writes.push(raw) + } + + expect(await writeTerminalImageOutput(image, { env: { TERM_PROGRAM: "Apple_Terminal" }, platform: "darwin", write })) + .toBe(false) + expect(await writeTerminalImageOutput("plain text", { env: { TERM_PROGRAM: "iTerm.app" }, platform: "darwin", write })) + .toBe(false) + expect(await writeTerminalImageOutput(image, { env: { TERM_PROGRAM: "iTerm.app" }, platform: "darwin", write })).toBe( + true, + ) + expect(writes).toEqual([image]) + }) + + test("builds iTerm2 OSC image output from local image files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(`${dir}/image.png`, Buffer.from("image")) + await Bun.write(`${dir}/image.txt`, "not an image") + return dir + }, + }) + + expect(await terminalImageOutputFromFile(`${tmp.path}/missing.png`)).toBeUndefined() + expect(await terminalImageOutputFromFile(`${tmp.path}/image.txt`)).toBeUndefined() + expect(await terminalImageOutputFromFile(`${tmp.path}/image.png`)).toBe( + "\x1b]1337;File=name=aW1hZ2UucG5n;inline=1;doNotMoveCursor=1:aW1hZ2U=\x07", + ) + expect(await terminalImageOutputFromFile(`${tmp.path}/image.png`, { width: 40, height: 12 })).toBe( + "\x1b]1337;File=name=aW1hZ2UucG5n;inline=1;doNotMoveCursor=1;width=40;height=12:aW1hZ2U=\x07", + ) + }) + + test("computes terminal-cell sectors from image dimensions", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const png = Buffer.alloc(24) + Buffer.from("89504e470d0a1a0a", "hex").copy(png, 0) + png.writeUInt32BE(900, 16) + png.writeUInt32BE(360, 20) + await Bun.write(`${dir}/wide.png`, png) + return dir + }, + }) + + expect(await terminalImageSizeFromFile(`${tmp.path}/wide.png`)).toEqual({ width: 900, height: 360 }) + expect(terminalImageSector({ width: 900, height: 360 }, { maxWidth: 80 })).toEqual({ columns: 80, rows: 16 }) + expect(terminalImageSector({ width: 900, height: 360 }, { maxWidth: 80, maxHeight: 8 })).toEqual({ + columns: 40, + rows: 8, + }) + expect(terminalImageSector({ width: 90, height: 36 }, { maxWidth: 80 })).toEqual({ columns: 10, rows: 2 }) + }) + + test("marks explicit OSC cell dimensions as a render sector", () => { + expect( + terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40;height=12:aW1hZ2U=\x07", { + maxWidth: 80, + }), + ).toEqual({ columns: 40, rows: 12 }) + expect( + terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=200;height=12:aW1hZ2U=\x07", { + maxWidth: 80, + }), + ).toEqual({ columns: 80, rows: 12 }) + expect( + terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40;height=20:aW1hZ2U=\x07", { + maxWidth: 80, + maxHeight: 8, + }), + ).toEqual({ columns: 40, rows: 8 }) + expect( + terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40px;height=12:aW1hZ2U=\x07", { + maxWidth: 80, + }), + ).toEqual({ columns: 80, rows: 12 }) + expect(terminalImageSectorFromOutput(image, { maxWidth: 80 })).toBeUndefined() + }) + + test("positions OSC image output inside a reserved terminal area", () => { + expect(terminalImagePlacementOutput(image, { x: 4, y: 2, width: 8, height: 3 })).toBe( + "\x1b7\x1b[3;5H\x1b[8X\x1b[4;5H\x1b[8X\x1b[5;5H\x1b[8X\x1b[3;5H" + image + "\x1b8", + ) + }) + + test("writes local image files only for supported terminal environments", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(`${dir}/image.png`, Buffer.from("image")) + return dir + }, + }) + const writes: string[] = [] + const write = async (raw: string) => { + writes.push(raw) + } + + expect( + await writeTerminalImageFileOutput(`${tmp.path}/image.png`, { + env: { TERM_PROGRAM: "Apple_Terminal" }, + platform: "darwin", + write, + }), + ).toBe(false) + expect( + await writeTerminalImageFileOutput(`${tmp.path}/image.png`, { + env: { TERM_PROGRAM: "iTerm.app" }, + platform: "darwin", + display: { width: 20, height: 10 }, + placement: { x: 1, y: 1, width: 20, height: 10 }, + write, + }), + ).toBe(true) + expect(writes).toEqual([ + "\x1b7" + + Array.from({ length: 10 }, (_, index) => `\x1b[${index + 2};2H\x1b[20X`).join("") + + "\x1b[2;2H\x1b]1337;File=name=aW1hZ2UucG5n;inline=1;doNotMoveCursor=1;width=20;height=10:aW1hZ2U=\x07\x1b8", + ]) + }) + + test("finds image paths from generic show_image inputs", () => { + expect(terminalImagePath({ path: "/tmp/image.png" })).toBe("/tmp/image.png") + expect(terminalImagePath({ filePath: "/tmp/image.png" })).toBe("/tmp/image.png") + expect(terminalImagePath({ file_path: "/tmp/image.png" })).toBe("/tmp/image.png") + expect(terminalImagePath({ path: 1 })).toBeUndefined() + }) +}) From fb8ba196876ad827815e6bc3f14da30577b49de1 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Fri, 15 May 2026 19:52:40 +0900 Subject: [PATCH 3/3] fix(tui): stabilize inline terminal images Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/cli/cmd/tui/routes/session/index.tsx | 154 ++++++++++++++---- .../src/cli/cmd/tui/util/terminal-image.ts | 50 ++++-- .../test/cli/tui/terminal-image.test.ts | 46 +++++- 3 files changed, 202 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9e8494f22d18..34903416a6f6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -92,6 +92,7 @@ import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut } from "../../keymap" import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" import { + clearTerminalImageOutput, hasTerminalImageOutput, supportsTerminalImageOutput, terminalImageSector, @@ -169,6 +170,7 @@ const fallbackTerminalImageRows = 12 const context = createContext<{ width: number height: number + scrollViewport: { y: number; height: number } | undefined sessionID: string conceal: () => boolean showThinking: () => boolean @@ -1101,6 +1103,10 @@ export function Session() { 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, @@ -1668,7 +1674,7 @@ function GenericTool(props: ToolProps) { ) const terminalOutputSupported = createMemo(() => supportsTerminalImageOutput()) const shouldRenderImageArea = createMemo(() => Boolean((hasTerminalOutput() || imagePath()) && terminalOutputSupported())) - const inlineImageMaxRows = createMemo(() => Math.max(1, Math.floor(ctx.height * 0.45))) + 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() ?? { @@ -1677,7 +1683,8 @@ function GenericTool(props: ToolProps) { } }) const [expanded, setExpanded] = createSignal() - const isExpanded = createMemo(() => expanded() ?? configuredOutput()) + 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)}` @@ -1694,23 +1701,65 @@ function GenericTool(props: ToolProps) { 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 | undefined let renderedInlineImageKey: string | undefined - let lastInlineImageWriteAt = 0 + 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 inlineImageKey = () => { + const inlineImagePlacement = () => { const box = imageBox() if (!box) return if (!props.output) return - if (!shouldRenderImageArea()) 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, - props.output.length, + raw.length, imagePath() ?? "", - Math.floor(box.screenX), - Math.floor(box.screenY), - inlineImageSector().columns, - Math.min(inlineImageSector().rows, Math.max(1, Math.floor(ctx.height - box.screenY - 2))), + Math.floor(placement.x), + Math.floor(placement.y), + placement.width, + placement.height, ].join(":") } @@ -1718,36 +1767,57 @@ function GenericTool(props: ToolProps) { if (!props.output) return if (!hasTerminalOutput() && !imagePath()) return if (!terminalOutputSupported()) return + if (!shouldShowInlineImage()) return if (!ctx.showGenericToolOutput() && !configuredOutput() && !imagePath()) return - const box = imageBox() - if (!box) return - const width = Math.min(Math.max(1, Math.floor(box.width)), inlineImageSector().columns) - const height = Math.min(inlineImageSector().rows, Math.max(1, Math.floor(ctx.height - box.screenY - 2))) + const placement = inlineImagePlacement() + if (!placement) return + renderedInlineImagePlacement = placement const raw = props.output const filePath = imagePath() if (hasTerminalImageOutput(raw)) { - await writeTerminalImageOutput(raw, { placement: { x: box.screenX, y: box.screenY, width, height } }) + await writeTerminalImageOutput(raw, { placement }) return } if (filePath) { await writeTerminalImageFileOutput(filePath, { - display: { width, height, preserveAspectRatio: true }, - placement: { x: box.screenX, y: box.screenY, width, height }, + display: { width: placement.width, height: placement.height, preserveAspectRatio: true }, + placement, }) } } - const scheduleInlineImageWrite = (force = false) => { + 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 - const delay = force ? Math.max(0, 250 - (Date.now() - lastInlineImageWriteAt)) : 25 - pendingInlineImageWrite = new Promise((resolve) => setTimeout(resolve, delay)) + pendingInlineImageWrite = new Promise((resolve) => setTimeout(resolve, 25)) .then(async () => { const key = inlineImageKey() if (!key) return - if (!force && key === renderedInlineImageKey) return + if (key === renderedInlineImageKey) return + if (renderedInlineImagePlacement) { + pendingInlineImageClear = renderedInlineImagePlacement + renderedInlineImagePlacement = undefined + renderedInlineImageKey = undefined + renderer.requestRender() + return + } renderedInlineImageKey = key await writeInlineImage() - lastInlineImageWriteAt = Date.now() + skipNextFrameImageWrite = true + renderer.requestRender() }) .finally(() => { pendingInlineImageWrite = undefined @@ -1771,7 +1841,7 @@ function GenericTool(props: ToolProps) { }) createEffect(() => { - if (!shouldRenderImageArea()) return + if (!shouldShowInlineImage()) return if (!imageBox()) return output() inlineImageSector() @@ -1780,11 +1850,37 @@ function GenericTool(props: ToolProps) { }) const frameCallback = async () => { - if (!shouldRenderImageArea()) return - void renderer.idle().then(() => scheduleInlineImageWrite(true)) + 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)) + onCleanup(() => { + renderer.removeFrameCallback(frameCallback) + void clearRenderedInlineImage() + }) + + createEffect(() => { + if (shouldShowInlineImage()) return + void clearRenderedInlineImage() + }) return ( ) { setExpanded((prev) => !(prev ?? configuredOutput())) : undefined} + onClick={canToggleExpanded() ? () => setExpanded((prev) => !(prev ?? defaultExpanded())) : undefined} > {visible()} - + setImageBox(box)} height={inlineImageSector().rows} flexShrink={0} /> - + {isExpanded() ? "Click to collapse" : "Click to expand"} diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts b/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts index ea5348ed20bd..b4ac9d3e9b68 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts @@ -85,6 +85,19 @@ export async function writeTerminalImageFileOutput( return true } +export async function clearTerminalImageOutput( + placement: TerminalImagePlacementOptions, + options: { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + write?: (raw: string) => Promise + } = {}, +) { + if (!supportsTerminalImageOutput(options.env, options.platform)) return false + await writeQueued(terminalImageClearOutput(placement), options.write) + return true +} + export async function terminalImageOutputFromFile(filePath: string, options: TerminalImageDisplayOptions = {}) { const mime = imageMime[path.extname(filePath).toLowerCase().slice(1) as keyof typeof imageMime] if (!mime) return @@ -111,13 +124,13 @@ export function terminalImageSector( const imageHeight = Math.max(1, size.height) const maxWidth = Math.max(1, Math.floor(options.maxWidth)) const maxHeight = Math.max(1, Math.floor(options.maxHeight ?? Number.POSITIVE_INFINITY)) - const columns = Math.min(maxWidth, Math.max(1, Math.ceil(imageWidth / cellWidth))) - const rows = Math.max(1, Math.ceil((imageHeight * ((columns * cellWidth) / imageWidth)) / cellHeight)) - if (rows <= maxHeight) return { columns, rows } - const heightBoundColumns = Math.max(1, Math.ceil((imageWidth * ((maxHeight * cellHeight) / imageHeight)) / cellWidth)) + const columns = Math.max(1, Math.ceil(imageWidth / cellWidth)) + const rows = Math.max(1, Math.ceil(imageHeight / cellHeight)) + if (columns <= maxWidth && rows <= maxHeight) return { columns, rows } + const scale = Math.min(maxWidth / columns, maxHeight / rows) return { - columns: Math.min(maxWidth, heightBoundColumns), - rows: maxHeight, + columns: Math.max(1, Math.floor(columns * scale)), + rows: Math.max(1, Math.floor(rows * scale)), } } @@ -127,20 +140,25 @@ export function terminalImageSectorFromOutput(raw: string, options: { maxWidth: const width = terminalImageCellParam(params, "width") const height = terminalImageCellParam(params, "height") if (!height) return + const maxWidth = Math.max(1, Math.floor(options.maxWidth)) const maxHeight = Math.max(1, Math.floor(options.maxHeight ?? Number.POSITIVE_INFINITY)) + const columns = width ?? maxWidth + if (columns <= maxWidth && height <= maxHeight) return { columns, rows: height } + const scale = Math.min(maxWidth / columns, maxHeight / height) return { - columns: Math.min(Math.max(1, Math.floor(options.maxWidth)), width ?? Math.max(1, Math.floor(options.maxWidth))), - rows: Math.min(height, maxHeight), + columns: Math.max(1, Math.floor(columns * scale)), + rows: Math.max(1, Math.floor(height * scale)), } } export function terminalImagePlacementOutput(raw: string, options: TerminalImagePlacementOptions) { const x = Math.max(1, Math.floor(options.x) + 1) const y = Math.max(1, Math.floor(options.y) + 1) - const width = Math.max(1, Math.floor(options.width)) - const height = Math.max(1, Math.floor(options.height)) - const clear = Array.from({ length: height }, (_, row) => `\x1b[${y + row};${x}H\x1b[${width}X`).join("") - return `\x1b7${clear}\x1b[${y};${x}H${raw}\x1b8` + return `\x1b7\x1b[${y};${x}H${raw}\x1b8` +} + +export function terminalImageClearOutput(options: TerminalImagePlacementOptions) { + return `\x1b7${terminalImageClearRows(options)}\x1b8` } export function terminalImagePath(input: Record | undefined) { @@ -149,6 +167,14 @@ export function terminalImagePath(input: Record | undefined) { return value } +function terminalImageClearRows(options: TerminalImagePlacementOptions) { + const x = Math.max(1, Math.floor(options.x) + 1) + const y = Math.max(1, Math.floor(options.y) + 1) + const width = Math.max(1, Math.floor(options.width)) + const height = Math.max(1, Math.floor(options.height)) + return Array.from({ length: height }, (_, row) => `\x1b[${y + row};${x}H\x1b[${width}X`).join("") +} + function supportsITermImageVersion(version: string | undefined) { if (!version) return true const parsed = version diff --git a/packages/opencode/test/cli/tui/terminal-image.test.ts b/packages/opencode/test/cli/tui/terminal-image.test.ts index 8b59687a20b3..0768acb4e129 100644 --- a/packages/opencode/test/cli/tui/terminal-image.test.ts +++ b/packages/opencode/test/cli/tui/terminal-image.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" import { + clearTerminalImageOutput, hasTerminalImageOutput, supportsTerminalImageOutput, + terminalImageClearOutput, terminalImageOutputFromFile, terminalImagePlacementOutput, terminalImagePath, @@ -104,15 +106,23 @@ describe("terminal image output", () => { }) expect(await terminalImageSizeFromFile(`${tmp.path}/wide.png`)).toEqual({ width: 900, height: 360 }) + expect(terminalImageSector({ width: 900, height: 360 }, { maxWidth: 120, maxHeight: 20 })).toEqual({ + columns: 100, + rows: 20, + }) expect(terminalImageSector({ width: 900, height: 360 }, { maxWidth: 80 })).toEqual({ columns: 80, rows: 16 }) expect(terminalImageSector({ width: 900, height: 360 }, { maxWidth: 80, maxHeight: 8 })).toEqual({ columns: 40, rows: 8, }) + expect(terminalImageSector({ width: 90, height: 360 }, { maxWidth: 80, maxHeight: 10 })).toEqual({ + columns: 5, + rows: 10, + }) expect(terminalImageSector({ width: 90, height: 36 }, { maxWidth: 80 })).toEqual({ columns: 10, rows: 2 }) }) - test("marks explicit OSC cell dimensions as a render sector", () => { + test("scales explicit OSC cell dimensions into the render sector", () => { expect( terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40;height=12:aW1hZ2U=\x07", { maxWidth: 80, @@ -122,13 +132,13 @@ describe("terminal image output", () => { terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=200;height=12:aW1hZ2U=\x07", { maxWidth: 80, }), - ).toEqual({ columns: 80, rows: 12 }) + ).toEqual({ columns: 80, rows: 4 }) expect( terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40;height=20:aW1hZ2U=\x07", { maxWidth: 80, maxHeight: 8, }), - ).toEqual({ columns: 40, rows: 8 }) + ).toEqual({ columns: 16, rows: 8 }) expect( terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40px;height=12:aW1hZ2U=\x07", { maxWidth: 80, @@ -139,10 +149,34 @@ describe("terminal image output", () => { test("positions OSC image output inside a reserved terminal area", () => { expect(terminalImagePlacementOutput(image, { x: 4, y: 2, width: 8, height: 3 })).toBe( - "\x1b7\x1b[3;5H\x1b[8X\x1b[4;5H\x1b[8X\x1b[5;5H\x1b[8X\x1b[3;5H" + image + "\x1b8", + "\x1b7\x1b[3;5H" + image + "\x1b8", ) }) + test("clears a reserved terminal image area", async () => { + expect(terminalImageClearOutput({ x: 4, y: 2, width: 8, height: 3 })).toBe( + "\x1b7\x1b[3;5H\x1b[8X\x1b[4;5H\x1b[8X\x1b[5;5H\x1b[8X\x1b8", + ) + const writes: string[] = [] + const write = async (raw: string) => { + writes.push(raw) + } + + expect( + await clearTerminalImageOutput( + { x: 4, y: 2, width: 8, height: 3 }, + { env: { TERM_PROGRAM: "Apple_Terminal" }, platform: "darwin", write }, + ), + ).toBe(false) + expect( + await clearTerminalImageOutput( + { x: 4, y: 2, width: 8, height: 3 }, + { env: { TERM_PROGRAM: "iTerm.app" }, platform: "darwin", write }, + ), + ).toBe(true) + expect(writes).toEqual(["\x1b7\x1b[3;5H\x1b[8X\x1b[4;5H\x1b[8X\x1b[5;5H\x1b[8X\x1b8"]) + }) + test("writes local image files only for supported terminal environments", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -172,9 +206,7 @@ describe("terminal image output", () => { }), ).toBe(true) expect(writes).toEqual([ - "\x1b7" + - Array.from({ length: 10 }, (_, index) => `\x1b[${index + 2};2H\x1b[20X`).join("") + - "\x1b[2;2H\x1b]1337;File=name=aW1hZ2UucG5n;inline=1;doNotMoveCursor=1;width=20;height=10:aW1hZ2U=\x07\x1b8", + "\x1b7\x1b[2;2H\x1b]1337;File=name=aW1hZ2UucG5n;inline=1;doNotMoveCursor=1;width=20;height=10:aW1hZ2U=\x07\x1b8", ]) })