diff --git a/bun.lock b/bun.lock index 8fa8d02544a5..211b4618f4df 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/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 70b5570ad550..708a6f226402 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,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) @@ -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 @@ -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, @@ -1639,34 +1662,247 @@ 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 [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() + 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 | 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((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 ( - {props.tool} {input(props.input)} + {title()} } > setExpanded((prev) => !prev) : undefined} + onClick={canToggleExpanded() ? () => setExpanded((prev) => !(prev ?? defaultExpanded())) : undefined} > - {limited()} - - {expanded() ? "Click to collapse" : "Click to expand"} + {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..b4ac9d3e9b68 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/terminal-image.ts @@ -0,0 +1,277 @@ +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 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 + 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.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.max(1, Math.floor(columns * scale)), + rows: Math.max(1, Math.floor(rows * scale)), + } +} + +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 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.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) + return `\x1b7\x1b[${y};${x}H${raw}\x1b8` +} + +export function terminalImageClearOutput(options: TerminalImagePlacementOptions) { + return `\x1b7${terminalImageClearRows(options)}\x1b8` +} + +export function terminalImagePath(input: Record | undefined) { + const value = input?.path ?? input?.filePath ?? input?.file_path + if (typeof value !== "string") return + 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 + .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..0768acb4e129 --- /dev/null +++ b/packages/opencode/test/cli/tui/terminal-image.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test" +import { + clearTerminalImageOutput, + hasTerminalImageOutput, + supportsTerminalImageOutput, + terminalImageClearOutput, + 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: 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("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, + }), + ).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: 4 }) + expect( + terminalImageSectorFromOutput("\x1b]1337;File=name=test.png;inline=1;width=40;height=20:aW1hZ2U=\x07", { + maxWidth: 80, + maxHeight: 8, + }), + ).toEqual({ columns: 16, 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" + 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) => { + 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\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() + }) +})