From 952e94d54095519ec733652c0dd49ebed28ef4ce Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:39:26 +0800 Subject: [PATCH 01/15] feat(cli): add TUI image display tool --- docs/design/tui-display-image-tool.md | 66 ++++++ .../src/ui/components/TerminalImage.test.tsx | 88 ++++++++ .../cli/src/ui/components/TerminalImage.tsx | 112 ++++++++++ .../components/messages/ToolMessage.test.tsx | 31 +++ .../ui/components/messages/ToolMessage.tsx | 16 ++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 4 +- .../ui/utils/terminal-image-renderer.test.ts | 176 +++++++++++++++ .../src/ui/utils/terminal-image-renderer.ts | 204 ++++++++++++++++++ packages/core/src/config/config.test.ts | 45 ++++ packages/core/src/config/config.ts | 11 + .../core/src/core/coreToolScheduler.test.ts | 8 + packages/core/src/core/coreToolScheduler.ts | 2 + packages/core/src/tools/display-image.test.ts | 104 +++++++++ packages/core/src/tools/display-image.ts | 191 ++++++++++++++++ packages/core/src/tools/tool-names.ts | 2 + packages/core/src/tools/tools.ts | 26 ++- 16 files changed, 1084 insertions(+), 2 deletions(-) create mode 100644 docs/design/tui-display-image-tool.md create mode 100644 packages/cli/src/ui/components/TerminalImage.test.tsx create mode 100644 packages/cli/src/ui/components/TerminalImage.tsx create mode 100644 packages/cli/src/ui/utils/terminal-image-renderer.test.ts create mode 100644 packages/cli/src/ui/utils/terminal-image-renderer.ts create mode 100644 packages/core/src/tools/display-image.test.ts create mode 100644 packages/core/src/tools/display-image.ts diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md new file mode 100644 index 00000000000..6f19b72421a --- /dev/null +++ b/docs/design/tui-display-image-tool.md @@ -0,0 +1,66 @@ +# TUI Display Image Tool + +## Goal + +Add a model-invocable `display_image` tool that previews an existing PNG in +the interactive terminal UI. The tool is display-only: it does not provide +image pixels to the model and is not available in headless, SDK, screen-reader, +or subagent registries. + +## Design + +The core tool validates an absolute workspace path, regular-file status, PNG +signature, and an 8 MiB size limit. On success it returns a small structured +display value: + +```ts +{ + type: 'terminal_image', + filePath: '/absolute/workspace/path/image.png', + mimeType: 'image/png', +} +``` + +`llmContent` only reports that the preview was shown and tells the model to use +`read_file` if it needs to inspect the image. Image bytes and terminal escape +sequences never enter model history or the persisted display value. + +`ToolMessage` recognizes the structured value and delegates it to a TUI +component. The component revalidates the path against the current workspace +before reading it. Rendering is prepared during the component's first render +because completed tool rows enter Ink's append-only `Static` region +immediately; an asynchronous state update would be dropped there. +Kitty-compatible terminals use the existing virtual-image and +Unicode-placeholder approach already used for Mermaid diagrams. Other +terminals try `chafa` with symbol output. `chafa` receives only the same +allowlisted environment used by the Mermaid renderer, so API credentials are +not forwarded. If neither path is available, the TUI shows a bounded text +fallback naming the image. + +The tool is registered only when all of these are true: + +- the main config is interactive; +- SDK mode is disabled; +- screen-reader mode is disabled; +- the registry is not being created for a subagent. + +Bare mode keeps its existing minimal tool set. + +## Compatibility + +The first version supports PNG only. Kitty and Ghostty use native image +placement. Direct iTerm2 placement is intentionally excluded because its inline +image protocol is cursor-positioned and the existing asynchronous Ink path +already treats it as unsafe. iTerm2, tmux, SSH, and terminals without a native +path can still render through `chafa` when it is installed. + +## Security and persistence + +Both the tool and renderer use `WorkspaceContext.isPathWithinWorkspace`, which +resolves symlinks and prevents traversal outside registered workspace roots. +The renderer treats restored `resultDisplay` data as untrusted and repeats this +check. Persisted sessions contain only the path and MIME type; if the file no +longer exists, restore degrades to a text notice. + +Raw terminal output is generated only by the trusted renderer. Model-controlled +strings and file contents are never written directly to the terminal. diff --git a/packages/cli/src/ui/components/TerminalImage.test.tsx b/packages/cli/src/ui/components/TerminalImage.test.tsx new file mode 100644 index 00000000000..23616313f33 --- /dev/null +++ b/packages/cli/src/ui/components/TerminalImage.test.tsx @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Static } from 'ink'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; +import { renderTerminalImage } from '../utils/terminal-image-renderer.js'; +import { TerminalImage } from './TerminalImage.js'; + +vi.mock('../utils/terminal-image-renderer.js', () => ({ + renderTerminalImage: vi.fn(), +})); + +const mockedRenderTerminalImage = vi.mocked(renderTerminalImage); + +function configWithWorkspaceResult(isWithinWorkspace: boolean): Config { + return { + getWorkspaceContext: () => ({ + isPathWithinWorkspace: () => isWithinWorkspace, + }), + } as unknown as Config; +} + +describe('TerminalImage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes trusted Kitty data and renders its placeholder', async () => { + const writeRaw = vi.fn(); + mockedRenderTerminalImage.mockReturnValueOnce({ + kind: 'kitty', + sequence: '\x1b_Gpayload\x1b\\', + placeholder: { + color: '#00002a', + imageId: 42, + lines: ['placeholder'], + }, + }); + + const image = { + type: 'terminal_image' as const, + filePath: '/workspace/chart.png', + mimeType: 'image/png' as const, + }; + const { lastFrame } = render( + + + {(item) => ( + + )} + + , + ); + + await vi.waitFor(() => { + expect(writeRaw).toHaveBeenCalledWith('\x1b_Gpayload\x1b\\'); + }); + expect(lastFrame()).toContain('placeholder'); + }); + + it('refuses restored paths outside the current workspace', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('outside the current workspace'); + expect(mockedRenderTerminalImage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/TerminalImage.tsx b/packages/cli/src/ui/components/TerminalImage.tsx new file mode 100644 index 00000000000..4ee7318f557 --- /dev/null +++ b/packages/cli/src/ui/components/TerminalImage.tsx @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import React from 'react'; +import { Box, Text } from 'ink'; +import type { Config, TerminalImageDisplay } from '@qwen-code/qwen-code-core'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { useTerminalOutput } from '../contexts/TerminalOutputContext.js'; +import { + renderTerminalImage, + type TerminalImageRenderResult, +} from '../utils/terminal-image-renderer.js'; +import { theme } from '../semantic-colors.js'; +import { + sanitizeMultilineForDisplay, + sanitizeTerminalText, +} from '../utils/textUtils.js'; + +interface TerminalImageProps { + data: TerminalImageDisplay; + config: Config; + contentWidth: number; + availableTerminalHeight?: number; +} + +export const TerminalImage: React.FC = ({ + data, + config, + contentWidth, + availableTerminalHeight, +}) => { + const writeRaw = useTerminalOutput(); + const writtenSequence = React.useRef(null); + const filePath = path.resolve(data.filePath); + const safePath = config.getWorkspaceContext().isPathWithinWorkspace(filePath); + const result = React.useMemo( + () => + safePath + ? renderTerminalImage({ + display: { + type: 'terminal_image', + filePath, + mimeType: data.mimeType, + }, + contentWidth, + availableTerminalHeight, + }) + : null, + [availableTerminalHeight, contentWidth, data.mimeType, filePath, safePath], + ); + + const kittySequence = result?.kind === 'kitty' ? result.sequence : null; + React.useEffect(() => { + writtenSequence.current = null; + }, [filePath]); + React.useEffect(() => { + if (!kittySequence || writtenSequence.current === kittySequence) return; + writtenSequence.current = kittySequence; + process.nextTick(() => writeRaw(kittySequence)); + }, [kittySequence, writeRaw]); + + const fileName = sanitizeMultilineForDisplay(path.basename(filePath)); + if (!safePath) { + return ( + + Refusing to display an image outside the current workspace. + + ); + } + if (!result) return null; + if (result.kind === 'unavailable') { + return ( + + {fileName}: {sanitizeTerminalText(result.reason)} + + ); + } + if (result.kind === 'ansi') { + return ( + + {result.lines.map((line, index) => ( + + {line || ' '} + + ))} + + ); + } + return ( + + {result.placeholder.lines.map((line, index) => ( + + + {line} + + + ))} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 5ec2dc101ab..7b13aa36adf 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -70,6 +70,18 @@ vi.mock('../AnsiOutput.js', () => ({ }, })); +vi.mock('../TerminalImage.js', () => ({ + TerminalImage: ({ + data, + }: { + data: { filePath: string; mimeType: string }; + }) => ( + + MockTerminalImage:{data.filePath}:{data.mimeType} + + ), +})); + // Mock child components or utilities if they are complex or have side effects vi.mock('../GeminiRespondingSpinner.js', () => ({ GeminiRespondingSpinner: ({ @@ -327,6 +339,25 @@ describe('', () => { expect(output).toContain('MockMarkdown:Test result'); // not collapsed }); + it('renders structured terminal image results through TerminalImage', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + expect(lastFrame()).toContain( + 'MockTerminalImage:/workspace/chart.png:image/png', + ); + }); + it('renders tool results directly below the header row when forced', () => { const { lastFrame } = renderWithContext( , diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 22c44d97dfe..c77ce7d451a 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -23,9 +23,11 @@ import type { Config, McpToolProgressData, FileDiff, + TerminalImageDisplay, } from '@qwen-code/qwen-code-core'; import { formatVisionBridgeNoticeDisplay, + isTerminalImageDisplay, isVisionBridgeNoticeDisplay, ToolNames, ToolNamesMigration, @@ -55,6 +57,7 @@ import { STATUS_INDICATOR_WIDTH, } from '../shared/ToolStatusIndicator.js'; import { ToolElapsedTime } from '../shared/ToolElapsedTime.js'; +import { TerminalImage } from '../TerminalImage.js'; // Names that resolve to the agent tool: the canonical name plus whatever // legacy request aliases core's migration map declares (e.g. 'task'). @@ -172,6 +175,7 @@ type DisplayRendererResult = | { type: 'string'; data: string } | { type: 'diff'; data: { fileDiff: string; fileName: string } } | { type: 'task'; data: AgentResultDisplay } + | { type: 'image'; data: TerminalImageDisplay } | { type: 'ansi'; data: AnsiOutput; stats?: ShellStatsBarProps }; /** @@ -185,6 +189,10 @@ const useResultDisplayRenderer = ( return { type: 'none' }; } + if (isTerminalImageDisplay(resultDisplay)) { + return { type: 'image', data: resultDisplay }; + } + // Check for TodoResultDisplay if ( typeof resultDisplay === 'object' && @@ -946,6 +954,14 @@ export const ToolMessage: React.FC = ({ )} )} + {effectiveDisplayRenderer.type === 'image' && config && ( + + )} {effectiveDisplayRenderer.type === 'string' && ( { + let tempDir: string; + let imagePath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'terminal-image-test-')); + imagePath = path.join(tempDir, 'pixel.png'); + await fs.writeFile(imagePath, PNG_1X1); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('renders a Kitty virtual image with placeholder rows', async () => { + const result = await renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 24, + availableTerminalHeight: 12, + env: { TERM: 'xterm-kitty' }, + stdoutIsTTY: true, + }); + + expect(result.kind).toBe('kitty'); + if (result.kind !== 'kitty') return; + expect(result.sequence).toContain('\u001b_Ga=T,f=100'); + expect(result.sequence).toContain('q=2,U=1'); + expect(result.placeholder.lines.length).toBeGreaterThan(0); + expect(result.placeholder.lines[0]).toContain('\u{10EEEE}'); + }); + + it('disables native placement in tmux, SSH, and non-TTY output', () => { + expect(supportsKittyImageProtocol({ TERM: 'xterm-kitty' }, true)).toBe( + true, + ); + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-kitty', TMUX: '/tmp/tmux' }, + true, + ), + ).toBe(false); + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-kitty', SSH_TTY: '/dev/pts/1' }, + true, + ), + ).toBe(false); + expect(supportsKittyImageProtocol({ TERM: 'xterm-kitty' }, false)).toBe( + false, + ); + }); + + it.runIf(process.platform !== 'win32')( + 'falls back to chafa symbol output', + async () => { + const binDir = path.join(tempDir, 'bin'); + await fs.mkdir(binDir); + const chafaPath = path.join(binDir, 'chafa'); + await fs.writeFile( + chafaPath, + '#!/usr/bin/env node\nprocess.stdout.write(process.env.TEST_RENDERER_SECRET ? "LEAKED\\n" : "\\x1b[31mFAKE_CHAFA\\x1b[0m\\n");\n', + ); + await fs.chmod(chafaPath, 0o755); + + const result = await renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + TEST_RENDERER_SECRET: 'must-not-reach-chafa', + }, + stdoutIsTTY: false, + }); + + expect(result).toEqual({ + kind: 'ansi', + lines: ['\x1b[31mFAKE_CHAFA\x1b[0m'], + }); + }, + ); + + it('returns a readable fallback when chafa is unavailable', async () => { + const result = await renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { PATH: tempDir }, + stdoutIsTTY: false, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'chafa is not installed', + ); + }); + + it('rejects missing and invalid PNG files during restored rendering', async () => { + const missing = await renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: path.join(tempDir, 'missing.png'), + mimeType: 'image/png', + }, + contentWidth: 20, + env: { TERM: 'xterm-kitty' }, + stdoutIsTTY: true, + }); + expect(missing.kind).toBe('unavailable'); + + await fs.writeFile(imagePath, 'not a png'); + const invalid = await renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { TERM: 'xterm-kitty' }, + stdoutIsTTY: true, + }); + expect(invalid).toEqual({ + kind: 'unavailable', + reason: 'Image is not a valid PNG.', + }); + + const oversizedPath = path.join(tempDir, 'oversized.png'); + const handle = await fs.open(oversizedPath, 'w'); + await handle.truncate(8 * 1024 * 1024 + 1); + await handle.close(); + const oversized = await renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: oversizedPath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { TERM: 'xterm-kitty' }, + stdoutIsTTY: true, + }); + expect(oversized.kind).toBe('unavailable'); + expect(oversized.kind === 'unavailable' && oversized.reason).toContain( + 'display limit', + ); + }); +}); diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts new file mode 100644 index 00000000000..8ce267930d9 --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + MAX_TERMINAL_IMAGE_BYTES, + type TerminalImageDisplay, +} from '@qwen-code/qwen-code-core'; +import { + buildKittyPlaceholder, + createRendererChildEnv, + encodeKittyVirtualImage, + readPngSize, + type KittyImagePlaceholder, +} from './mermaidImageRenderer.js'; + +const CHAFA_TIMEOUT_MS = 8000; +const CHAFA_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; + +export type TerminalImageRenderResult = + | { + kind: 'kitty'; + sequence: string; + placeholder: KittyImagePlaceholder; + } + | { kind: 'ansi'; lines: string[] } + | { kind: 'unavailable'; reason: string }; + +export interface TerminalImageRenderOptions { + display: TerminalImageDisplay; + contentWidth: number; + availableTerminalHeight?: number; + env?: NodeJS.ProcessEnv; + stdoutIsTTY?: boolean; +} + +export function supportsKittyImageProtocol( + env: NodeJS.ProcessEnv = process.env, + stdoutIsTTY = process.stdout.isTTY, +): boolean { + if (!stdoutIsTTY || env['TMUX'] || env['SSH_TTY'] || env['SSH_CLIENT']) { + return false; + } + + const term = env['TERM']?.toLowerCase() ?? ''; + const termProgram = env['TERM_PROGRAM']?.toLowerCase() ?? ''; + return Boolean( + env['KITTY_WINDOW_ID'] || + term.includes('kitty') || + termProgram.includes('ghostty'), + ); +} + +export function renderTerminalImage({ + display, + contentWidth, + availableTerminalHeight, + env = process.env, + stdoutIsTTY = process.stdout.isTTY, +}: TerminalImageRenderOptions): TerminalImageRenderResult { + let stat: fs.Stats; + try { + stat = fs.statSync(display.filePath); + } catch (error) { + return { + kind: 'unavailable', + reason: `Image file is unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + if (!stat.isFile()) { + return { + kind: 'unavailable', + reason: 'Image path is not a regular file.', + }; + } + if (stat.size > MAX_TERMINAL_IMAGE_BYTES) { + return { + kind: 'unavailable', + reason: `Image exceeds the ${MAX_TERMINAL_IMAGE_BYTES} byte display limit.`, + }; + } + + let png: Buffer; + try { + png = fs.readFileSync(display.filePath); + } catch (error) { + return { + kind: 'unavailable', + reason: `Unable to read image: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + if (png.length > MAX_TERMINAL_IMAGE_BYTES) { + return { + kind: 'unavailable', + reason: `Image exceeds the ${MAX_TERMINAL_IMAGE_BYTES} byte display limit.`, + }; + } + const size = readPngSize(png); + if (!size) { + return { kind: 'unavailable', reason: 'Image is not a valid PNG.' }; + } + + const shape = fitImageToTerminal(size, contentWidth, availableTerminalHeight); + if (supportsKittyImageProtocol(env, stdoutIsTTY)) { + const imageId = createImageId(png, shape); + return { + kind: 'kitty', + sequence: encodeKittyVirtualImage( + png, + imageId, + shape.widthCells, + shape.rows, + ), + placeholder: buildKittyPlaceholder(imageId, shape.widthCells, shape.rows), + }; + } + + return renderWithChafa(display.filePath, shape, env); +} + +function fitImageToTerminal( + size: { width: number; height: number }, + contentWidth: number, + availableTerminalHeight?: number, +): { widthCells: number; rows: number } { + const widthCells = Math.max(4, Math.min(Math.floor(contentWidth), 120)); + const naturalRows = Math.ceil((size.height / size.width) * widthCells * 0.5); + const maxRows = Math.max( + 4, + Math.min(Math.floor(availableTerminalHeight ?? 32), 60), + ); + return { + widthCells, + rows: Math.max(4, Math.min(naturalRows, maxRows)), + }; +} + +function createImageId( + png: Buffer, + shape: { widthCells: number; rows: number }, +): number { + const hash = crypto + .createHash('sha256') + .update(png) + .update('\0') + .update(String(shape.widthCells)) + .update('\0') + .update(String(shape.rows)) + .digest(); + const id = hash.readUIntBE(0, 3); + return id === 0 ? 1 : id; +} + +function renderWithChafa( + filePath: string, + shape: { widthCells: number; rows: number }, + env: NodeJS.ProcessEnv, +): TerminalImageRenderResult { + try { + const stdout = execFileSync( + 'chafa', + [ + '--animate=off', + '--format=symbols', + '--symbols=block', + `--size=${shape.widthCells}x${shape.rows}`, + filePath, + ], + { + encoding: 'utf8', + env: createRendererChildEnv(env), + maxBuffer: CHAFA_MAX_OUTPUT_BYTES, + timeout: CHAFA_TIMEOUT_MS, + }, + ); + const lines = stdout.split(/\r?\n/).filter((line) => line.length > 0); + return lines.length > 0 + ? { kind: 'ansi', lines } + : { kind: 'unavailable', reason: 'chafa produced no output.' }; + } catch (error) { + const execError = error as Error & { + code?: string | number; + stderr?: Buffer | string; + }; + return { + kind: 'unavailable', + reason: + execError.code === 'ENOENT' + ? 'This terminal does not support Kitty images and chafa is not installed.' + : String(execError.stderr ?? '').trim() || + execError.message || + 'chafa could not render the image.', + }; + } +} diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c4a6003fe5d..de32ae9e269 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3825,6 +3825,51 @@ describe('Server Config (config.ts)', () => { expect(registeredNames).toContain(ToolNames.RECORD_ARTIFACT); }); + it('registers display_image only for the main interactive TUI', async () => { + const interactive = new Config({ + ...baseParams, + interactive: true, + sdkMode: false, + }); + await interactive.initialize(); + + const registerToolMock = ToolRegistry.prototype.registerFactory as Mock; + expect(registerToolMock.mock.calls.map((call) => call[0])).toContain( + ToolNames.DISPLAY_IMAGE, + ); + + for (const params of [ + { interactive: false, sdkMode: false }, + { interactive: true, sdkMode: true }, + { + interactive: true, + sdkMode: false, + inputFormat: InputFormat.STREAM_JSON, + }, + { + interactive: true, + sdkMode: false, + accessibility: { screenReader: true }, + }, + ]) { + registerToolMock.mockClear(); + const config = new Config({ ...baseParams, ...params }); + await config.initialize(); + expect( + registerToolMock.mock.calls.map((call) => call[0]), + ).not.toContain(ToolNames.DISPLAY_IMAGE); + } + + registerToolMock.mockClear(); + await interactive.createToolRegistry(undefined, { + skipDiscovery: true, + forSubAgent: true, + }); + expect(registerToolMock.mock.calls.map((call) => call[0])).not.toContain( + ToolNames.DISPLAY_IMAGE, + ); + }); + it('registers only record_artifact by default for daemon artifact metadata', async () => { const config = new Config({ ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 06ecf2eb949..e316ce7999b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -7932,6 +7932,17 @@ export class Config { const { WebFetchTool } = await import('../tools/web-fetch.js'); return new WebFetchTool(this); }); + if ( + resolveInteractionMode(this) === 'interactive' && + !this.sdkMode && + !this.getScreenReader() && + !options?.forSubAgent + ) { + await registerLazy(ToolNames.DISPLAY_IMAGE, async () => { + const { DisplayImageTool } = await import('../tools/display-image.js'); + return new DisplayImageTool(this); + }); + } // WebSearch is opt-in: it registers only when explicitly enabled AND the // configured search model resolves to a usable DashScope entry. A failed // gate surfaces a one-time startup notice instead of a silently missing diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 9c21fe4a83c..1f4a63da67c 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -14834,6 +14834,14 @@ describe('extractToolFilePaths', () => { ).toEqual(['/proj/chart.png']); }); + it('extracts file_path for display_image', () => { + expect( + extractToolFilePaths(ToolNames.DISPLAY_IMAGE, { + file_path: '/proj/chart.png', + }), + ).toEqual(['/proj/chart.png']); + }); + it('extracts notebook_path for notebook_edit', () => { expect( extractToolFilePaths('notebook_edit', { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 54f53613c18..3d4ff0226c8 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -519,6 +519,7 @@ const FS_PATH_TOOL_NAMES: ReadonlySet = new Set([ ToolNames.LS, ToolNames.LSP, ToolNames.NOTEBOOK_EDIT, + ToolNames.DISPLAY_IMAGE, ]); /** @@ -716,6 +717,7 @@ export function extractToolFilePaths( case ToolNames.ZOOM_IMAGE: case ToolNames.EDIT: case ToolNames.WRITE_FILE: + case ToolNames.DISPLAY_IMAGE: push(obj['file_path']); return out; diff --git a/packages/core/src/tools/display-image.test.ts b/packages/core/src/tools/display-image.test.ts new file mode 100644 index 00000000000..7e59560d840 --- /dev/null +++ b/packages/core/src/tools/display-image.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Config } from '../config/config.js'; +import { ToolErrorType } from './tool-error.js'; +import { DisplayImageTool } from './display-image.js'; +import { MAX_TERMINAL_IMAGE_BYTES } from './tools.js'; + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +describe('DisplayImageTool', () => { + let workspace: string; + let tool: DisplayImageTool; + + beforeEach(async () => { + workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'display-image-test-')); + tool = new DisplayImageTool({ + getTargetDir: () => workspace, + getWorkspaceContext: () => ({ + isPathWithinWorkspace: (filePath: string) => { + const relative = path.relative(workspace, path.resolve(filePath)); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ); + }, + }), + } as unknown as Config); + }); + + afterEach(async () => { + await fs.rm(workspace, { recursive: true, force: true }); + }); + + it('returns a small structured display without image bytes', async () => { + const imagePath = path.join(workspace, 'pixel.png'); + await fs.writeFile(imagePath, PNG_1X1); + + const invocation = tool.build({ file_path: imagePath }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + expect(result.returnDisplay).toEqual({ + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }); + expect(result.resultFilePaths).toEqual([imagePath]); + expect(result.llmContent).toContain('does not provide the image contents'); + expect(JSON.stringify(result)).not.toContain(PNG_1X1.toString('base64')); + expect(invocation.toolLocations()).toEqual([{ path: imagePath }]); + }); + + it('requires an absolute workspace path', () => { + expect(() => tool.build({ file_path: 'pixel.png' })).toThrow( + /must be absolute/, + ); + expect(() => + tool.build({ file_path: path.join(os.tmpdir(), 'outside.png') }), + ).toThrow(/outside the current workspace/); + }); + + it('rejects missing files and directories', async () => { + const missing = await tool + .build({ file_path: path.join(workspace, 'missing.png') }) + .execute(new AbortController().signal); + expect(missing.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND); + + const directory = await tool + .build({ file_path: workspace }) + .execute(new AbortController().signal); + expect(directory.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY); + }); + + it('rejects non-PNG and oversized files', async () => { + const textPath = path.join(workspace, 'not-an-image.png'); + await fs.writeFile(textPath, 'not a png'); + const nonPng = await tool + .build({ file_path: textPath }) + .execute(new AbortController().signal); + expect(nonPng.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + + const largePath = path.join(workspace, 'large.png'); + const handle = await fs.open(largePath, 'w'); + await handle.truncate(MAX_TERMINAL_IMAGE_BYTES + 1); + await handle.close(); + const oversized = await tool + .build({ file_path: largePath }) + .execute(new AbortController().signal); + expect(oversized.error?.type).toBe(ToolErrorType.FILE_TOO_LARGE); + }); +}); diff --git a/packages/core/src/tools/display-image.ts b/packages/core/src/tools/display-image.ts new file mode 100644 index 00000000000..37b64706c58 --- /dev/null +++ b/packages/core/src/tools/display-image.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { Config } from '../config/config.js'; +import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js'; +import { ToolErrorType } from './tool-error.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + MAX_TERMINAL_IMAGE_BYTES, + type TerminalImageDisplay, + type ToolInvocation, + type ToolLocation, + type ToolResult, +} from './tools.js'; + +const PNG_SIGNATURE = '89504e470d0a1a0a'; + +export interface DisplayImageToolParams { + file_path: string; +} + +function errorResult(message: string, type: ToolErrorType): ToolResult { + return { + llmContent: message, + returnDisplay: `Error: ${message}`, + error: { message, type }, + }; +} + +class DisplayImageInvocation extends BaseToolInvocation< + DisplayImageToolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: DisplayImageToolParams, + ) { + super(params); + } + + getDescription(): string { + return shortenPath( + makeRelative(this.params.file_path, this.config.getTargetDir()), + ); + } + + override toolLocations(): ToolLocation[] { + return [{ path: this.params.file_path }]; + } + + async execute(signal: AbortSignal): Promise { + signal.throwIfAborted(); + const filePath = path.resolve(this.params.file_path); + + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + } catch (error) { + const message = + (error as NodeJS.ErrnoException).code === 'ENOENT' + ? `Image file not found: ${filePath}` + : `Unable to access image file "${filePath}": ${ + error instanceof Error ? error.message : String(error) + }`; + return errorResult( + message, + (error as NodeJS.ErrnoException).code === 'ENOENT' + ? ToolErrorType.FILE_NOT_FOUND + : ToolErrorType.READ_CONTENT_FAILURE, + ); + } + + if (stat.isDirectory()) { + return errorResult( + `Image path is a directory: ${filePath}`, + ToolErrorType.TARGET_IS_DIRECTORY, + ); + } + if (!stat.isFile()) { + return errorResult( + `Image path is not a regular file: ${filePath}`, + ToolErrorType.TARGET_NOT_REGULAR_FILE, + ); + } + if (stat.size > MAX_TERMINAL_IMAGE_BYTES) { + return errorResult( + `Image exceeds the ${MAX_TERMINAL_IMAGE_BYTES} byte display limit: ${filePath}`, + ToolErrorType.FILE_TOO_LARGE, + ); + } + + signal.throwIfAborted(); + let fileHandle: Awaited> | undefined; + try { + fileHandle = await fs.open(filePath, 'r'); + const header = Buffer.alloc(8); + const { bytesRead } = await fileHandle.read(header, 0, header.length, 0); + if ( + bytesRead !== header.length || + header.toString('hex') !== PNG_SIGNATURE + ) { + return errorResult( + `Only PNG images are supported by display_image: ${filePath}`, + ToolErrorType.INVALID_TOOL_PARAMS, + ); + } + } catch (error) { + return errorResult( + `Unable to read image file "${filePath}": ${ + error instanceof Error ? error.message : String(error) + }`, + ToolErrorType.READ_CONTENT_FAILURE, + ); + } finally { + await fileHandle?.close(); + } + + const display: TerminalImageDisplay = { + type: 'terminal_image', + filePath, + mimeType: 'image/png', + }; + return { + llmContent: + `Requested a terminal preview of "${path.basename(filePath)}". ` + + 'This display-only tool does not provide the image contents to you; use read_file if you need to inspect the image.', + returnDisplay: display, + resultFilePaths: [filePath], + }; + } +} + +export class DisplayImageTool extends BaseDeclarativeTool< + DisplayImageToolParams, + ToolResult +> { + static readonly Name = ToolNames.DISPLAY_IMAGE; + + constructor(private readonly config: Config) { + super( + DisplayImageTool.Name, + ToolDisplayNames.DISPLAY_IMAGE, + 'Displays an existing workspace PNG in the user’s interactive terminal. This is only a user-visible preview: it does not return image contents to you. Use read_file when you need to inspect or understand the image. The file_path must be absolute.', + Kind.Read, + { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute path to a PNG inside the current workspace.', + }, + }, + required: ['file_path'], + additionalProperties: false, + }, + false, + ); + } + + protected override validateToolParamValues( + params: DisplayImageToolParams, + ): string | null { + params.file_path = unescapePath(params.file_path.trim()); + if (!params.file_path) { + return 'Missing or empty "file_path".'; + } + if (!path.isAbsolute(params.file_path)) { + return `File path must be absolute: ${params.file_path}`; + } + if ( + !this.config.getWorkspaceContext().isPathWithinWorkspace(params.file_path) + ) { + return `Image path is outside the current workspace: ${params.file_path}`; + } + return null; + } + + protected createInvocation( + params: DisplayImageToolParams, + ): ToolInvocation { + return new DisplayImageInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index b6478ca4977..66c80dc3ff1 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -69,6 +69,7 @@ export const ToolNames = { RECORD_ARTIFACT: 'record_artifact', GET_GOAL: 'get_goal', UPDATE_GOAL: 'update_goal', + DISPLAY_IMAGE: 'display_image', } as const; /** @@ -123,6 +124,7 @@ export const ToolDisplayNames = { RECORD_ARTIFACT: 'RecordArtifact', GET_GOAL: 'Goal', UPDATE_GOAL: 'UpdateGoal', + DISPLAY_IMAGE: 'DisplayImage', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 23d0e8efb0c..ead5ea6ad14 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -711,6 +711,29 @@ export function isShellProgressData( ); } +export const MAX_TERMINAL_IMAGE_BYTES = 8 * 1024 * 1024; + +export interface TerminalImageDisplay { + type: 'terminal_image'; + filePath: string; + mimeType: 'image/png'; +} + +export function isTerminalImageDisplay( + display: unknown, +): display is TerminalImageDisplay { + return ( + typeof display === 'object' && + display !== null && + 'type' in display && + display.type === 'terminal_image' && + 'filePath' in display && + typeof display.filePath === 'string' && + 'mimeType' in display && + display.mimeType === 'image/png' + ); +} + export type ToolResultDisplay = | string | FileDiff @@ -722,7 +745,8 @@ export type ToolResultDisplay = | AnsiOutputDisplay | McpToolProgressData | VisionBridgeNoticeDisplay - | ShellProgressData; + | ShellProgressData + | TerminalImageDisplay; export interface TeamResultDisplay { type: 'team_result'; From 3d8818eb98be12f542b074e7fecbad978aa1db07 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:01:16 +0800 Subject: [PATCH 02/15] fix(cli): report terminal image rendering failures --- docs/design/tui-display-image-tool.md | 12 ++++-- packages/cli/src/config/config.ts | 8 ++++ .../ui/utils/terminal-image-renderer.test.ts | 40 +++++++++++++++++++ .../src/ui/utils/terminal-image-renderer.ts | 24 +++++++++-- packages/core/src/config/config.test.ts | 28 +++++++++++++ packages/core/src/config/config.ts | 23 +++++++++++ packages/core/src/tools/display-image.test.ts | 26 ++++++++++++ packages/core/src/tools/display-image.ts | 11 ++++- 8 files changed, 164 insertions(+), 8 deletions(-) diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md index 6f19b72421a..b649c7e287e 100644 --- a/docs/design/tui-display-image-tool.md +++ b/docs/design/tui-display-image-tool.md @@ -21,9 +21,13 @@ display value: } ``` -`llmContent` only reports that the preview was shown and tells the model to use -`read_file` if it needs to inspect the image. Image bytes and terminal escape -sequences never enter model history or the persisted display value. +Before returning success, the tool asks a CLI-injected provider whether the +current terminal has a working renderer. Native protocols pass immediately; +the fallback performs a small `chafa` render using the selected file. If no +renderer is available, the tool returns an execution error so both the TUI and +the model know that the image was not displayed. `llmContent` tells the model +to use `read_file` if it needs to inspect the image. Image bytes and terminal +escape sequences never enter model history or the persisted display value. `ToolMessage` recognizes the structured value and delegates it to a TUI component. The component revalidates the path against the current workspace @@ -48,7 +52,7 @@ Bare mode keeps its existing minimal tool set. ## Compatibility -The first version supports PNG only. Kitty and Ghostty use native image +The first version supports PNG only. Kitty, Ghostty, and Warp use native image placement. Direct iTerm2 placement is intentionally excluded because its inline image protocol is cursor-positioned and the existing asynchronous Ink path already treats it as unsafe. iTerm2, tmux, SSH, and terminals without a native diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9fceff2488f..46b794ddb1e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2068,6 +2068,14 @@ export async function loadCliConfig( disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined, disabledSkillNamesProvider: bareMode || safeMode ? undefined : disabledSkillNamesProvider, + terminalImageRenderSupportProvider: interactive + ? async (filePath: string) => { + const { getTerminalImageRenderSupport } = await import( + '../ui/utils/terminal-image-renderer.js' + ); + return getTerminalImageRenderSupport(filePath); + } + : undefined, customSkillDirs: bareMode || safeMode ? undefined diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 46ad1badeab..7b70e3fef56 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + getTerminalImageRenderSupport, renderTerminalImage, supportsKittyImageProtocol, } from './terminal-image-renderer.js'; @@ -74,6 +75,29 @@ describe('terminalImageRenderer', () => { ); }); + it('recognizes Warp as supporting the Kitty image protocol', () => { + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + true, + ), + ).toBe(true); + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + true, + 'win32', + ), + ).toBe(false); + expect( + getTerminalImageRenderSupport( + imagePath, + { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + true, + ), + ).toEqual({ available: true }); + }); + it.runIf(process.platform !== 'win32')( 'falls back to chafa symbol output', async () => { @@ -104,6 +128,15 @@ describe('terminalImageRenderer', () => { kind: 'ansi', lines: ['\x1b[31mFAKE_CHAFA\x1b[0m'], }); + expect( + getTerminalImageRenderSupport( + imagePath, + { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + }, + false, + ), + ).toEqual({ available: true }); }, ); @@ -123,6 +156,13 @@ describe('terminalImageRenderer', () => { expect(result.kind === 'unavailable' && result.reason).toContain( 'chafa is not installed', ); + expect( + getTerminalImageRenderSupport(imagePath, { PATH: tempDir }, true), + ).toEqual({ + available: false, + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }); }); it('rejects missing and invalid PNG files during restored rendering', async () => { diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index 8ce267930d9..cf43271bd91 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -9,6 +9,7 @@ import fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import { MAX_TERMINAL_IMAGE_BYTES, + type TerminalImageRenderSupport, type TerminalImageDisplay, } from '@qwen-code/qwen-code-core'; import { @@ -42,6 +43,7 @@ export interface TerminalImageRenderOptions { export function supportsKittyImageProtocol( env: NodeJS.ProcessEnv = process.env, stdoutIsTTY = process.stdout.isTTY, + platform: NodeJS.Platform = process.platform, ): boolean { if (!stdoutIsTTY || env['TMUX'] || env['SSH_TTY'] || env['SSH_CLIENT']) { return false; @@ -52,10 +54,26 @@ export function supportsKittyImageProtocol( return Boolean( env['KITTY_WINDOW_ID'] || term.includes('kitty') || - termProgram.includes('ghostty'), + termProgram.includes('ghostty') || + (termProgram === 'warpterminal' && platform !== 'win32'), ); } +export function getTerminalImageRenderSupport( + filePath: string, + env: NodeJS.ProcessEnv = process.env, + stdoutIsTTY = process.stdout.isTTY, +): TerminalImageRenderSupport { + if (supportsKittyImageProtocol(env, stdoutIsTTY)) { + return { available: true }; + } + + const result = renderWithChafa(filePath, { widthCells: 4, rows: 4 }, env); + return result.kind === 'ansi' + ? { available: true } + : { available: false, reason: result.reason }; +} + export function renderTerminalImage({ display, contentWidth, @@ -164,7 +182,7 @@ function renderWithChafa( filePath: string, shape: { widthCells: number; rows: number }, env: NodeJS.ProcessEnv, -): TerminalImageRenderResult { +): Extract { try { const stdout = execFileSync( 'chafa', @@ -195,7 +213,7 @@ function renderWithChafa( kind: 'unavailable', reason: execError.code === 'ENOENT' - ? 'This terminal does not support Kitty images and chafa is not installed.' + ? 'No compatible native image protocol was detected, and chafa is not installed.' : String(execError.stderr ?? '').trim() || execError.message || 'chafa could not render the image.', diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index de32ae9e269..992b751d9e1 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3870,6 +3870,34 @@ describe('Server Config (config.ts)', () => { ); }); + it('forwards terminal image renderer support to the display tool', async () => { + const provider = vi.fn().mockResolvedValue({ + available: false, + reason: 'renderer unavailable', + }); + const config = new Config({ + ...baseParams, + terminalImageRenderSupportProvider: provider, + }); + + await expect( + config.getTerminalImageRenderSupport('/workspace/image.png'), + ).resolves.toEqual({ + available: false, + reason: 'renderer unavailable', + }); + expect(provider).toHaveBeenCalledWith('/workspace/image.png'); + + await expect( + new Config(baseParams).getTerminalImageRenderSupport( + '/workspace/image.png', + ), + ).resolves.toEqual({ + available: false, + reason: 'No terminal image renderer is configured.', + }); + }); + it('registers only record_artifact by default for daemon artifact metadata', async () => { const config = new Config({ ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e316ce7999b..903548646f9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -961,6 +961,9 @@ export interface ConfigParameters { * Names returned must be lower-cased; consumers compare case-insensitively. */ disabledSkillNamesProvider?: () => ReadonlySet; + terminalImageRenderSupportProvider?: ( + filePath: string, + ) => Promise; /** * Additional directories to scan for skills (SKILL.md files). * Sourced from `settings.skills.directories`. Paths are raw @@ -1351,6 +1354,10 @@ export interface ConfigParameters { settingsWatcher?: { stopWatching(): void }; } +export type TerminalImageRenderSupport = + | { available: true } + | { available: false; reason: string }; + export interface ImageGenerationConfig { model: string; baseUrl: string; @@ -1778,6 +1785,9 @@ export class Config { private readonly disabledSkillNamesProvider: | (() => ReadonlySet) | null; + private readonly terminalImageRenderSupportProvider: + | ((filePath: string) => Promise) + | null; private readonly customSkillDirs: readonly string[]; // `disabledTools` is set at construction // time but can be re-synced by the daemon mutation surface @@ -2091,6 +2101,8 @@ export class Config { ...(params.disabledSlashCommands ?? []), ]); this.disabledSkillNamesProvider = params.disabledSkillNamesProvider ?? null; + this.terminalImageRenderSupportProvider = + params.terminalImageRenderSupportProvider ?? null; this.customSkillDirs = Object.freeze([...(params.customSkillDirs ?? [])]); this.disabledTools = new Set(params.disabledTools ?? []); this.visibleTools = new Set( @@ -7025,6 +7037,17 @@ export class Config { return this.interactive; } + async getTerminalImageRenderSupport( + filePath: string, + ): Promise { + return this.terminalImageRenderSupportProvider + ? this.terminalImageRenderSupportProvider(filePath) + : { + available: false, + reason: 'No terminal image renderer is configured.', + }; + } + getUseRipgrep(): boolean { return this.useRipgrep; } diff --git a/packages/core/src/tools/display-image.test.ts b/packages/core/src/tools/display-image.test.ts index 7e59560d840..4a1021e374d 100644 --- a/packages/core/src/tools/display-image.test.ts +++ b/packages/core/src/tools/display-image.test.ts @@ -37,6 +37,7 @@ describe('DisplayImageTool', () => { ); }, }), + getTerminalImageRenderSupport: async () => ({ available: true }), } as unknown as Config); }); @@ -72,6 +73,31 @@ describe('DisplayImageTool', () => { ).toThrow(/outside the current workspace/); }); + it('reports a renderer failure to the model and TUI', async () => { + const imagePath = path.join(workspace, 'pixel.png'); + await fs.writeFile(imagePath, PNG_1X1); + tool = new DisplayImageTool({ + getTargetDir: () => workspace, + getWorkspaceContext: () => ({ + isPathWithinWorkspace: () => true, + }), + getTerminalImageRenderSupport: async () => ({ + available: false, + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }), + } as unknown as Config); + + const result = await tool + .build({ file_path: imagePath }) + .execute(new AbortController().signal); + + expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED); + expect(result.llmContent).toContain('The image was not displayed'); + expect(result.returnDisplay).toContain('The image was not displayed'); + expect(result.returnDisplay).not.toHaveProperty('type', 'terminal_image'); + }); + it('rejects missing files and directories', async () => { const missing = await tool .build({ file_path: path.join(workspace, 'missing.png') }) diff --git a/packages/core/src/tools/display-image.ts b/packages/core/src/tools/display-image.ts index 37b64706c58..1a958c0a837 100644 --- a/packages/core/src/tools/display-image.ts +++ b/packages/core/src/tools/display-image.ts @@ -123,6 +123,15 @@ class DisplayImageInvocation extends BaseToolInvocation< await fileHandle?.close(); } + const renderSupport = + await this.config.getTerminalImageRenderSupport(filePath); + if (!renderSupport.available) { + return errorResult( + `The image was not displayed: ${renderSupport.reason}`, + ToolErrorType.EXECUTION_FAILED, + ); + } + const display: TerminalImageDisplay = { type: 'terminal_image', filePath, @@ -130,7 +139,7 @@ class DisplayImageInvocation extends BaseToolInvocation< }; return { llmContent: - `Requested a terminal preview of "${path.basename(filePath)}". ` + + `The terminal renderer accepted "${path.basename(filePath)}" for display. ` + 'This display-only tool does not provide the image contents to you; use read_file if you need to inspect the image.', returnDisplay: display, resultFilePaths: [filePath], From 6814fe814af906a9d014dae7466ac9763ad4f4f1 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:24:55 +0800 Subject: [PATCH 03/15] fix(cli): disable Kitty placeholders in Warp --- docs/design/tui-display-image-tool.md | 12 +++++---- .../ui/utils/terminal-image-renderer.test.ts | 27 +++++++++++++------ .../src/ui/utils/terminal-image-renderer.ts | 8 +++--- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md index b649c7e287e..e63916b285b 100644 --- a/docs/design/tui-display-image-tool.md +++ b/docs/design/tui-display-image-tool.md @@ -52,11 +52,13 @@ Bare mode keeps its existing minimal tool set. ## Compatibility -The first version supports PNG only. Kitty, Ghostty, and Warp use native image -placement. Direct iTerm2 placement is intentionally excluded because its inline -image protocol is cursor-positioned and the existing asynchronous Ink path -already treats it as unsafe. iTerm2, tmux, SSH, and terminals without a native -path can still render through `chafa` when it is installed. +The first version supports PNG only. Kitty and Ghostty use native image +placement. Warp is intentionally excluded because its Kitty support does not +include the Unicode-placeholder extension used by this renderer. Direct iTerm2 +placement is also excluded because its inline image protocol is +cursor-positioned and the existing asynchronous Ink path already treats it as +unsafe. Warp, iTerm2, tmux, SSH, and terminals without a native path can still +render through `chafa` when it is installed. ## Security and persistence diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 7b70e3fef56..bd1cb4fd094 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -75,27 +75,34 @@ describe('terminalImageRenderer', () => { ); }); - it('recognizes Warp as supporting the Kitty image protocol', () => { + it('does not use Kitty Unicode placeholders in Warp', () => { expect( supportsKittyImageProtocol( { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, true, ), - ).toBe(true); + ).toBe(false); expect( supportsKittyImageProtocol( - { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + { TERM: 'xterm-kitty', TERM_PROGRAM: 'WarpTerminal' }, true, - 'win32', ), ).toBe(false); expect( getTerminalImageRenderSupport( imagePath, - { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + { + PATH: tempDir, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, true, ), - ).toEqual({ available: true }); + ).toEqual({ + available: false, + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }); }); it.runIf(process.platform !== 'win32')( @@ -119,9 +126,11 @@ describe('terminalImageRenderer', () => { contentWidth: 20, env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', TEST_RENDERER_SECRET: 'must-not-reach-chafa', }, - stdoutIsTTY: false, + stdoutIsTTY: true, }); expect(result).toEqual({ @@ -133,8 +142,10 @@ describe('terminalImageRenderer', () => { imagePath, { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', }, - false, + true, ), ).toEqual({ available: true }); }, diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index cf43271bd91..d021e10d1a8 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -43,7 +43,6 @@ export interface TerminalImageRenderOptions { export function supportsKittyImageProtocol( env: NodeJS.ProcessEnv = process.env, stdoutIsTTY = process.stdout.isTTY, - platform: NodeJS.Platform = process.platform, ): boolean { if (!stdoutIsTTY || env['TMUX'] || env['SSH_TTY'] || env['SSH_CLIENT']) { return false; @@ -51,11 +50,14 @@ export function supportsKittyImageProtocol( const term = env['TERM']?.toLowerCase() ?? ''; const termProgram = env['TERM_PROGRAM']?.toLowerCase() ?? ''; + if (termProgram === 'warpterminal') { + return false; + } + return Boolean( env['KITTY_WINDOW_ID'] || term.includes('kitty') || - termProgram.includes('ghostty') || - (termProgram === 'warpterminal' && platform !== 'win32'), + termProgram.includes('ghostty'), ); } From 2abf06bc86a197ade28b638a83b008d1e670db6e Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:37:34 +0800 Subject: [PATCH 04/15] fix(cli): constrain terminal image previews --- docs/design/tui-display-image-tool.md | 5 ++ .../ui/utils/terminal-image-renderer.test.ts | 70 ++++++++++++++++++- .../src/ui/utils/terminal-image-renderer.ts | 32 +++++++-- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md index e63916b285b..ac9083854a5 100644 --- a/docs/design/tui-display-image-tool.md +++ b/docs/design/tui-display-image-tool.md @@ -41,6 +41,11 @@ allowlisted environment used by the Mermaid renderer, so API credentials are not forwarded. If neither path is available, the TUI shows a bounded text fallback naming the image. +Previews are capped at 72 columns and 24 rows, then reduced further to fit the +available terminal space. An 8-by-16-pixel terminal cell estimate provides a +conservative natural size so small images are not deliberately enlarged. Both +native and `chafa` rendering use the same aspect-preserving dimensions. + The tool is registered only when all of these are true: - the main config is interactive; diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index bd1cb4fd094..db14d08ec0d 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -19,6 +19,33 @@ const PNG_1X1 = Buffer.from( 'base64', ); +function pngWithSize(width: number, height: number): Buffer { + const png = Buffer.from(PNG_1X1); + png.writeUInt32BE(width, 16); + png.writeUInt32BE(height, 20); + return png; +} + +const IMAGE_SIZE_CASES: Array< + [ + name: string, + imageWidth: number, + imageHeight: number, + contentWidth: number, + availableTerminalHeight: number | undefined, + expectedWidth: number, + expectedRows: number, + ] +> = [ + ['keeps a small landscape at its natural size', 320, 160, 200, 100, 40, 10], + ['does not round a small image up', 12, 24, 200, 100, 1, 1], + ['caps a large landscape by width', 1600, 800, 200, 100, 72, 18], + ['caps a large square by default height', 1600, 1600, 200, undefined, 48, 24], + ['fits a large portrait without distortion', 800, 1600, 200, 100, 24, 24], + ['respects a narrower terminal', 1600, 1600, 30, 100, 30, 15], + ['respects a shorter terminal', 1600, 1600, 200, 10, 20, 10], +]; + describe('terminalImageRenderer', () => { let tempDir: string; let imagePath: string; @@ -50,10 +77,46 @@ describe('terminalImageRenderer', () => { if (result.kind !== 'kitty') return; expect(result.sequence).toContain('\u001b_Ga=T,f=100'); expect(result.sequence).toContain('q=2,U=1'); - expect(result.placeholder.lines.length).toBeGreaterThan(0); + expect(result.sequence).toContain('c=1,r=1'); + expect(result.placeholder.lines).toHaveLength(1); expect(result.placeholder.lines[0]).toContain('\u{10EEEE}'); }); + it.each(IMAGE_SIZE_CASES)( + '%s', + async ( + _name, + imageWidth, + imageHeight, + contentWidth, + availableTerminalHeight, + expectedWidth, + expectedRows, + ) => { + await fs.writeFile(imagePath, pngWithSize(imageWidth, imageHeight)); + + const result = renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth, + availableTerminalHeight, + env: { TERM: 'xterm-kitty' }, + stdoutIsTTY: true, + }); + + expect(result.kind).toBe('kitty'); + if (result.kind !== 'kitty') return; + expect(result.sequence).toContain(`c=${expectedWidth},r=${expectedRows}`); + expect(result.placeholder.lines).toHaveLength(expectedRows); + expect(result.placeholder.lines[0].split('\u{10EEEE}').length - 1).toBe( + expectedWidth, + ); + }, + ); + it('disables native placement in tmux, SSH, and non-TTY output', () => { expect(supportsKittyImageProtocol({ TERM: 'xterm-kitty' }, true)).toBe( true, @@ -113,9 +176,10 @@ describe('terminalImageRenderer', () => { const chafaPath = path.join(binDir, 'chafa'); await fs.writeFile( chafaPath, - '#!/usr/bin/env node\nprocess.stdout.write(process.env.TEST_RENDERER_SECRET ? "LEAKED\\n" : "\\x1b[31mFAKE_CHAFA\\x1b[0m\\n");\n', + '#!/usr/bin/env node\nprocess.stdout.write(process.env.TEST_RENDERER_SECRET ? "LEAKED\\n" : `${process.argv.find((arg) => arg.startsWith("--size="))}\\n`);\n', ); await fs.chmod(chafaPath, 0o755); + await fs.writeFile(imagePath, pngWithSize(1600, 800)); const result = await renderTerminalImage({ display: { @@ -135,7 +199,7 @@ describe('terminalImageRenderer', () => { expect(result).toEqual({ kind: 'ansi', - lines: ['\x1b[31mFAKE_CHAFA\x1b[0m'], + lines: ['--size=20x5'], }); expect( getTerminalImageRenderSupport( diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index d021e10d1a8..700bd03e964 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -22,6 +22,10 @@ import { const CHAFA_TIMEOUT_MS = 8000; const CHAFA_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const MAX_PREVIEW_WIDTH_CELLS = 72; +const MAX_PREVIEW_ROWS = 24; +const ESTIMATED_CELL_WIDTH_PX = 8; +const ESTIMATED_CELL_HEIGHT_PX = 16; export type TerminalImageRenderResult = | { @@ -152,15 +156,31 @@ function fitImageToTerminal( contentWidth: number, availableTerminalHeight?: number, ): { widthCells: number; rows: number } { - const widthCells = Math.max(4, Math.min(Math.floor(contentWidth), 120)); - const naturalRows = Math.ceil((size.height / size.width) * widthCells * 0.5); + const maxWidthCells = Math.max( + 1, + Math.min(Math.floor(contentWidth), MAX_PREVIEW_WIDTH_CELLS), + ); const maxRows = Math.max( - 4, - Math.min(Math.floor(availableTerminalHeight ?? 32), 60), + 1, + Math.min( + Math.floor(availableTerminalHeight ?? MAX_PREVIEW_ROWS), + MAX_PREVIEW_ROWS, + ), + ); + const naturalWidthCells = Math.max(1, size.width / ESTIMATED_CELL_WIDTH_PX); + const naturalRows = Math.max(1, size.height / ESTIMATED_CELL_HEIGHT_PX); + const scale = Math.min( + 1, + maxWidthCells / naturalWidthCells, + maxRows / naturalRows, ); + return { - widthCells, - rows: Math.max(4, Math.min(naturalRows, maxRows)), + widthCells: Math.max( + 1, + Math.min(maxWidthCells, Math.floor(naturalWidthCells * scale)), + ), + rows: Math.max(1, Math.min(maxRows, Math.floor(naturalRows * scale))), }; } From 05c11b40c68a46b5d77a54de95526054fa393bbb Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:57 +0800 Subject: [PATCH 05/15] fix(core): preserve fork image tool cache prefix --- .../agents/background-agent-resume.test.ts | 80 ++++++++++++++++--- .../src/agents/background-agent-resume.ts | 17 +++- packages/core/src/tools/agent/agent.test.ts | 57 +++++++++++++ packages/core/src/tools/agent/agent.ts | 37 ++++++--- .../src/tools/agent/fork-subagent.test.ts | 39 ++++++++- .../core/src/tools/agent/fork-subagent.ts | 38 +++++++++ packages/core/src/tools/display-image.ts | 2 +- 7 files changed, 246 insertions(+), 24 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index 80fd2bdfb61..9e4017a749e 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -108,8 +108,19 @@ describe('BackgroundAgentResumeService', () => { // mocks the override helper throws and every resume test fails. const stubToolRegistry = { copyDiscoveredToolsFrom: vi.fn(), + registerFactory: vi.fn(), getAllTools: vi.fn().mockReturnValue([]), - getAllToolNames: vi.fn().mockReturnValue([]), + getAllToolNames: vi + .fn() + .mockReturnValue( + ( + options.currentForkRuntime?.registeredTools ?? + options.currentForkRuntime?.advertisedTools ?? + [] + ) + .map((declaration) => declaration.name) + .filter((name): name is string => Boolean(name)), + ), getTool: vi.fn(), stop: vi.fn().mockResolvedValue(undefined), warmAll: vi.fn().mockResolvedValue(undefined), @@ -2030,15 +2041,27 @@ describe('BackgroundAgentResumeService', () => { tools: [{ name: 'Bash' }, { name: 'mcp__removed__search' }], }, executionAllowedTools: ['Read'] as string[] | undefined, + includeDisplayImage: false, }, { format: 'history-only bootstrap', legacyCapabilities: {}, executionAllowedTools: undefined as string[] | undefined, + includeDisplayImage: false, + }, + { + format: 'legacy fork without a persisted display policy', + legacyCapabilities: {}, + executionAllowedTools: undefined as string[] | undefined, + includeDisplayImage: true, }, ])( 'resumes fork agents with the current parent prompt and live tool registry ($format)', - async ({ legacyCapabilities, executionAllowedTools }) => { + async ({ + legacyCapabilities, + executionAllowedTools, + includeDisplayImage, + }) => { const sessionId = 'session-fork-resume'; const agentId = 'agent-fork-resume'; const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); @@ -2131,17 +2154,26 @@ describe('BackgroundAgentResumeService', () => { const subagent = await originalCreate(...args); vi.spyOn(subagent, 'execute').mockImplementation(async (context) => { executeContext = context; - if (executionAllowedTools === undefined) { + if (executionAllowedTools === undefined && !includeDisplayImage) { return; } + const deniedTool = includeDisplayImage + ? ToolNames.DISPLAY_IMAGE + : 'Edit'; const denial = await subagent .getCore() .processFunctionCalls( - [{ id: 'call-edit', name: 'Edit', args: {} }], + [{ id: 'call-denied', name: deniedTool, args: {} }], new AbortController(), 'resume-policy-test', 1, - [{ name: 'Read' }, { name: 'Edit' }], + [ + { name: 'Read' }, + ...(includeDisplayImage + ? [{ name: ToolNames.DISPLAY_IMAGE }] + : []), + { name: 'Edit' }, + ], ); deniedError = denial.messages[0]?.parts?.[0]?.functionResponse?.response?.[ @@ -2163,11 +2195,27 @@ describe('BackgroundAgentResumeService', () => { systemInstruction: currentSystemInstruction, advertisedTools: [ { name: 'Read', description: 'advertised current schema' }, + ...(includeDisplayImage + ? [ + { + name: ToolNames.DISPLAY_IMAGE, + description: 'advertised display schema', + }, + ] + : []), { name: 'Edit', description: 'advertised edit schema' }, { name: 'mcp__removed__search' }, ], registeredTools: [ { name: 'Read', description: 'registered current schema' }, + ...(includeDisplayImage + ? [ + { + name: ToolNames.DISPLAY_IMAGE, + description: 'registered display schema', + }, + ] + : []), { name: 'Edit', description: 'registered edit schema' }, ], }, @@ -2197,9 +2245,17 @@ describe('BackgroundAgentResumeService', () => { max_turns: FORK_DEFAULT_MAX_TURNS, }); expect(createArgs?.[5]).toEqual({ - tools: ['Read', 'Edit'], - ...(executionAllowedTools !== undefined - ? { executionAllowedTools } + tools: [ + 'Read', + ...(includeDisplayImage ? [ToolNames.DISPLAY_IMAGE] : []), + 'Edit', + ], + ...(executionAllowedTools !== undefined || includeDisplayImage + ? { + executionAllowedTools: includeDisplayImage + ? ['Read', 'Edit'] + : executionAllowedTools, + } : {}), }); expect(executeContext).toBeDefined(); @@ -2214,7 +2270,7 @@ describe('BackgroundAgentResumeService', () => { 'Earlier capability listings in the conversation history are obsolete', ); expect(contextArg.get('task_prompt')).toContain('continue'); - if (executionAllowedTools !== undefined) { + if (executionAllowedTools !== undefined || includeDisplayImage) { expect(deniedError).toContain('execution allowlist'); expect(deniedError).not.toContain('not found'); expect(stubToolRegistry.getTool).not.toHaveBeenCalled(); @@ -2222,6 +2278,12 @@ describe('BackgroundAgentResumeService', () => { } else { expect(deniedError).toBeUndefined(); } + if (includeDisplayImage) { + expect(stubToolRegistry.registerFactory).toHaveBeenCalledWith( + ToolNames.DISPLAY_IMAGE, + expect.any(Function), + ); + } createSpy.mockRestore(); }, ); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index d03bd588037..6c59a453b7a 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -50,6 +50,8 @@ import { FORK_AGENT, FORK_DEFAULT_MAX_TURNS, FORK_SUBAGENT_TYPE, + registerForkDisplayImageForCache, + resolveForkExecutionAllowedTools, runInForkContext, } from '../tools/agent/fork-subagent.js'; import { @@ -904,6 +906,12 @@ export class BackgroundAgentResumeService { const activeRestoreParentPM = approvalOverride.cleanup; agentConfig = activeAgentConfig; restoreParentPM = activeRestoreParentPM; + if (target.isFork) { + registerForkDisplayImageForCache( + activeAgentConfig, + currentForkRuntime!.toolNames, + ); + } // Mirror the launch path's permission-bubbling gate (agent.ts): an // agent whose definition uses `approvalMode: bubble` surfaces // confirmations to the parent UI instead of auto-denying, in @@ -1662,14 +1670,19 @@ export class BackgroundAgentResumeService { runtime: CurrentForkRuntime, executionAllowedTools?: string[], ): Promise { + const allowedTools = resolveForkExecutionAllowedTools( + runtime.toolNames, + executionAllowedTools, + agentConfig.getToolRegistry().getAllToolNames(), + ); const promptConfig: PromptConfig = { renderedSystemPrompt: structuredClone(runtime.systemInstruction), initialMessages, }; const toolConfig: ToolConfig = { tools: [...runtime.toolNames], - ...(executionAllowedTools !== undefined - ? { executionAllowedTools: structuredClone(executionAllowedTools) } + ...(allowedTools !== undefined + ? { executionAllowedTools: allowedTools } : {}), }; diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index ba5d5941314..bc7e6354419 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -191,6 +191,7 @@ describe('AgentTool', () => { // exercises foreground execution fails. const stubToolRegistry = { copyDiscoveredToolsFrom: vi.fn(), + registerFactory: vi.fn(), getAllTools: vi.fn().mockReturnValue([]), getAllToolNames: vi.fn().mockReturnValue([]), stop: vi.fn().mockResolvedValue(undefined), @@ -3421,6 +3422,62 @@ describe('AgentTool', () => { ); }); + it('preserves display_image in the fork declarations but denies its execution', async () => { + const parentToolDecls = [ + { + name: ToolNames.READ_FILE, + description: 'Read a file', + parameters: { type: 'object', properties: {} }, + }, + { + name: ToolNames.DISPLAY_IMAGE, + description: 'Display an image', + parameters: { type: 'object', properties: {} }, + }, + { + name: ToolNames.EDIT, + description: 'Edit a file', + parameters: { type: 'object', properties: {} }, + }, + ]; + vi.mocked(config.getGeminiClient).mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + getChat: vi.fn().mockReturnValue({ + getGenerationConfig: vi.fn().mockReturnValue({ + systemInstruction: 'parent system', + tools: [{ functionDeclarations: parentToolDecls }], + }), + }), + } as unknown as ReturnType); + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation({ + description: 'inspect images', + prompt: 'inspect the implementation', + subagent_type: 'fork', + fork_tools: [ToolNames.READ_FILE, ToolNames.DISPLAY_IMAGE], + }); + await invocation.execute(); + + const createArgs = vi.mocked(AgentHeadless.create).mock.calls[0]; + const forkConfig = createArgs?.[1]; + const toolConfig = createArgs?.[5]; + expect(toolConfig?.tools).toStrictEqual([ + ToolNames.READ_FILE, + ToolNames.DISPLAY_IMAGE, + ToolNames.EDIT, + ]); + expect(toolConfig?.executionAllowedTools).toEqual([ToolNames.READ_FILE]); + expect( + forkConfig?.getToolRegistry().registerFactory, + ).toHaveBeenCalledWith(ToolNames.DISPLAY_IMAGE, expect.any(Function)); + expect(mockContextState.set).toHaveBeenCalledWith( + 'task_prompt', + expect.stringContaining(JSON.stringify([ToolNames.READ_FILE])), + ); + }); + it('omitting subagent_type uses general-purpose, not fork', async () => { // Omission resolves to the regular general-purpose subagent, never a // context-inheriting fork — even in interactive mode. diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 13b3d4faecf..c965aaf3ed7 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -45,6 +45,8 @@ import { buildPinnedWorktreeNotice, buildWorktreeNotice, normalizeForkTurns, + registerForkDisplayImageForCache, + resolveForkExecutionAllowedTools, runInForkContext, selectForkHistory, type ForkTurns, @@ -1646,6 +1648,18 @@ class AgentToolInvocation extends BaseToolInvocation { toolConfig: ToolConfig; }> { const geminiClient = this.config.getGeminiClient(); + const generationConfig = geminiClient?.getChat().getGenerationConfig(); + const parentToolNames = generationConfig?.systemInstruction + ? extractParentToolNames(generationConfig) + : []; + registerForkDisplayImageForCache(agentConfig, parentToolNames); + const executionAllowedTools = resolveForkExecutionAllowedTools( + parentToolNames, + this.params.fork_tools, + agentConfig.getToolRegistry().getAllToolNames(), + ); + const promptExecutionAllowedTools = + this.params.fork_tools === undefined ? undefined : executionAllowedTools; const forkTurns = normalizeForkTurns(this.params.fork_turns); let rawHistory: Content[] = []; if (geminiClient) { @@ -1700,7 +1714,7 @@ class AgentToolInvocation extends BaseToolInvocation { const forkedMessages = buildForkedMessages( this.params.prompt, lastMessage, - this.params.fork_tools, + promptExecutionAllowedTools, ); if (forkedMessages.length > 0) { // Model had function calls: append tool responses + directive, @@ -1732,7 +1746,7 @@ class AgentToolInvocation extends BaseToolInvocation { if (!taskPrompt) { taskPrompt = buildChildMessage( this.params.prompt, - this.params.fork_tools, + promptExecutionAllowedTools, ); } @@ -1744,15 +1758,12 @@ class AgentToolInvocation extends BaseToolInvocation { let promptConfig: PromptConfig; let toolConfig: ToolConfig; - const generationConfig = geminiClient?.getChat().getGenerationConfig(); if (generationConfig?.systemInstruction) { // Keep the parent's current allowlist, but pass names rather than inline // schemas so AgentCore resolves every declaration through the fork's // current ToolRegistry. This preserves the parent's tool surface and // cache prefix when schemas are unchanged without letting a persisted or // stale declaration bypass the live registry. - const parentToolNames = extractParentToolNames(generationConfig); - promptConfig = { renderedSystemPrompt: generationConfig.systemInstruction as | string @@ -1761,8 +1772,8 @@ class AgentToolInvocation extends BaseToolInvocation { }; toolConfig = { tools: parentToolNames.length > 0 ? parentToolNames : ['*'], - ...(this.params.fork_tools !== undefined - ? { executionAllowedTools: [...this.params.fork_tools] } + ...(executionAllowedTools !== undefined + ? { executionAllowedTools } : {}), }; } else { @@ -1772,8 +1783,8 @@ class AgentToolInvocation extends BaseToolInvocation { }; toolConfig = { tools: ['*'], - ...(this.params.fork_tools !== undefined - ? { executionAllowedTools: [...this.params.fork_tools] } + ...(executionAllowedTools !== undefined + ? { executionAllowedTools } : {}), }; } @@ -3199,7 +3210,9 @@ class AgentToolInvocation extends BaseToolInvocation { isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, - ...(isFork && bgToolConfig?.executionAllowedTools !== undefined + ...(isFork && + this.params.fork_tools !== undefined && + bgToolConfig?.executionAllowedTools !== undefined ? { executionAllowedTools: [...bgToolConfig.executionAllowedTools], } @@ -3994,7 +4007,9 @@ class AgentToolInvocation extends BaseToolInvocation { isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, - ...(isFork && toolConfig?.executionAllowedTools !== undefined + ...(isFork && + this.params.fork_tools !== undefined && + toolConfig?.executionAllowedTools !== undefined ? { executionAllowedTools: [...toolConfig.executionAllowedTools], } diff --git a/packages/core/src/tools/agent/fork-subagent.test.ts b/packages/core/src/tools/agent/fork-subagent.test.ts index c4be428b491..af3bbdc5284 100644 --- a/packages/core/src/tools/agent/fork-subagent.test.ts +++ b/packages/core/src/tools/agent/fork-subagent.test.ts @@ -6,7 +6,44 @@ import type { Content } from '@google/genai'; import { describe, expect, it } from 'vitest'; -import { normalizeForkTurns, selectForkHistory } from './fork-subagent.js'; +import { ToolNames } from '../tool-names.js'; +import { + normalizeForkTurns, + resolveForkExecutionAllowedTools, + selectForkHistory, +} from './fork-subagent.js'; + +describe('resolveForkExecutionAllowedTools', () => { + const parentTools = [ + ToolNames.READ_FILE, + ToolNames.DISPLAY_IMAGE, + ToolNames.EDIT, + ]; + + it('preserves unrestricted execution when display_image is not advertised', () => { + expect( + resolveForkExecutionAllowedTools([ToolNames.READ_FILE], undefined), + ).toBeUndefined(); + }); + + it('keeps inherited declarations but denies display_image by default', () => { + expect( + resolveForkExecutionAllowedTools(parentTools, undefined, [ + ...parentTools, + ToolNames.GLOB, + ]), + ).toEqual([ToolNames.READ_FILE, ToolNames.EDIT, ToolNames.GLOB]); + }); + + it('cannot re-enable display_image through fork_tools', () => { + expect( + resolveForkExecutionAllowedTools(parentTools, [ + ToolNames.DISPLAY_IMAGE, + ToolNames.READ_FILE, + ]), + ).toEqual([ToolNames.READ_FILE]); + }); +}); describe('selectForkHistory', () => { const startup: Content = { diff --git a/packages/core/src/tools/agent/fork-subagent.ts b/packages/core/src/tools/agent/fork-subagent.ts index 419b56b4b40..ad308d6b0f4 100644 --- a/packages/core/src/tools/agent/fork-subagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -1,11 +1,13 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Content } from '@google/genai'; +import type { Config } from '../../config/config.js'; import type { SubagentConfig } from '../../subagents/types.js'; import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js'; import { getStartupContextLength, isSystemReminderContent, } from '../../utils/environmentContext.js'; +import { ToolNames } from '../tool-names.js'; export const FORK_SUBAGENT_TYPE = 'fork'; @@ -60,6 +62,42 @@ export function isInForkExecution(): boolean { return forkExecutionStorage.getStore() !== undefined; } +/** + * Keeps the fork's model-visible declarations cache-identical while removing + * the main-session-only image renderer from its execution capability. + */ +export function resolveForkExecutionAllowedTools( + advertisedToolNames: readonly string[], + requestedToolNames: readonly string[] | undefined, + availableToolNames: readonly string[] = advertisedToolNames, +): string[] | undefined { + if (!advertisedToolNames.includes(ToolNames.DISPLAY_IMAGE)) { + return requestedToolNames ? [...requestedToolNames] : undefined; + } + + return (requestedToolNames ?? availableToolNames).filter( + (name) => name !== ToolNames.DISPLAY_IMAGE, + ); +} + +/** + * Restores the parent's display schema in a fork registry for prompt-cache + * parity. Callers must pair this with resolveForkExecutionAllowedTools(). + */ +export function registerForkDisplayImageForCache( + config: Config, + advertisedToolNames: readonly string[], +): void { + if (!advertisedToolNames.includes(ToolNames.DISPLAY_IMAGE)) return; + + config + .getToolRegistry() + .registerFactory(ToolNames.DISPLAY_IMAGE, async () => { + const { DisplayImageTool } = await import('../display-image.js'); + return new DisplayImageTool(config); + }); +} + export const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'; diff --git a/packages/core/src/tools/display-image.ts b/packages/core/src/tools/display-image.ts index 1a958c0a837..f8c3a5e684f 100644 --- a/packages/core/src/tools/display-image.ts +++ b/packages/core/src/tools/display-image.ts @@ -157,7 +157,7 @@ export class DisplayImageTool extends BaseDeclarativeTool< super( DisplayImageTool.Name, ToolDisplayNames.DISPLAY_IMAGE, - 'Displays an existing workspace PNG in the user’s interactive terminal. This is only a user-visible preview: it does not return image contents to you. Use read_file when you need to inspect or understand the image. The file_path must be absolute.', + 'Displays an existing workspace PNG in the user’s interactive terminal. Only the main agent can execute this tool. This is only a user-visible preview: it does not return image contents to you. Use read_file when you need to inspect or understand the image. The file_path must be absolute.', Kind.Read, { type: 'object', From f19c16f3fa815caa63754354816b756d30912217 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 31 Jul 2026 18:44:52 +0000 Subject: [PATCH 06/15] fix(cli): address display_image review feedback (#8217) Add the missing DisplayImage zh/zh-TW locale entries (and en for parity) that broke the i18n guard test, detect chafa via a PATH lookup instead of a synchronous probe render during display_image execution, and reject truncated PNGs by reading the full 24-byte header before reporting success. --- packages/cli/src/config/config.ts | 4 +- packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../src/ui/components/TerminalImage.test.tsx | 86 +++++++++++++------ .../ui/utils/terminal-image-renderer.test.ts | 6 +- .../src/ui/utils/terminal-image-renderer.ts | 33 ++++++- packages/core/src/config/config.test.ts | 10 +-- packages/core/src/config/config.ts | 12 +-- packages/core/src/tools/agent/agent.ts | 6 +- packages/core/src/tools/display-image.ts | 10 ++- 11 files changed, 111 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3c3d470de27..e80f4d62ae6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2095,11 +2095,11 @@ export async function loadCliConfig( disabledSkillNamesProvider: bareMode || safeMode ? undefined : disabledSkillNamesProvider, terminalImageRenderSupportProvider: interactive - ? async (filePath: string) => { + ? async () => { const { getTerminalImageRenderSupport } = await import( '../ui/utils/terminal-image-renderer.js' ); - return getTerminalImageRenderSupport(filePath); + return getTerminalImageRenderSupport(); } : undefined, customSkillDirs: diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4ee231dbcb9..6534a68e2b5 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -192,6 +192,7 @@ export default { 'toolDisplayName.Agent': 'toolDisplayName.Agent', 'toolDisplayName.Artifact': 'toolDisplayName.Artifact', 'toolDisplayName.RecordArtifact': 'toolDisplayName.RecordArtifact', + 'toolDisplayName.DisplayImage': 'toolDisplayName.DisplayImage', 'toolDisplayName.Skill': 'toolDisplayName.Skill', 'toolDisplayName.EnterPlanMode': 'toolDisplayName.EnterPlanMode', 'toolDisplayName.ExitPlanMode': 'toolDisplayName.ExitPlanMode', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 08c5e41ad68..9e95f9c507d 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -183,6 +183,7 @@ export default { 'toolDisplayName.Agent': 'Agent', 'toolDisplayName.Artifact': '製品', 'toolDisplayName.RecordArtifact': '記錄製品', + 'toolDisplayName.DisplayImage': '顯示圖片', 'toolDisplayName.Skill': '技能', 'toolDisplayName.EnterPlanMode': '進入計畫模式', 'toolDisplayName.ExitPlanMode': '退出計畫模式', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 95d293cece6..08ef1754e54 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -184,6 +184,7 @@ export default { 'toolDisplayName.Agent': 'Agent', 'toolDisplayName.Artifact': '制品', 'toolDisplayName.RecordArtifact': '记录制品', + 'toolDisplayName.DisplayImage': '显示图片', 'toolDisplayName.Skill': '技能', 'toolDisplayName.EnterPlanMode': '进入计划模式', 'toolDisplayName.ExitPlanMode': '退出计划模式', diff --git a/packages/cli/src/ui/components/TerminalImage.test.tsx b/packages/cli/src/ui/components/TerminalImage.test.tsx index 23616313f33..78941ffaabf 100644 --- a/packages/cli/src/ui/components/TerminalImage.test.tsx +++ b/packages/cli/src/ui/components/TerminalImage.test.tsx @@ -9,7 +9,10 @@ import { render } from 'ink-testing-library'; import { Static } from 'ink'; import type { Config } from '@qwen-code/qwen-code-core'; import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; -import { renderTerminalImage } from '../utils/terminal-image-renderer.js'; +import { + renderTerminalImage, + type TerminalImageRenderResult, +} from '../utils/terminal-image-renderer.js'; import { TerminalImage } from './TerminalImage.js'; vi.mock('../utils/terminal-image-renderer.js', () => ({ @@ -26,6 +29,33 @@ function configWithWorkspaceResult(isWithinWorkspace: boolean): Config { } as unknown as Config; } +const IMAGE = { + type: 'terminal_image' as const, + filePath: '/workspace/chart.png', + mimeType: 'image/png' as const, +}; + +function renderImage( + result: TerminalImageRenderResult, + writeRaw: (...args: unknown[]) => void = vi.fn(), +) { + mockedRenderTerminalImage.mockReturnValueOnce(result); + return render( + + + {(item) => ( + + )} + + , + ); +} + describe('TerminalImage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -33,34 +63,17 @@ describe('TerminalImage', () => { it('writes trusted Kitty data and renders its placeholder', async () => { const writeRaw = vi.fn(); - mockedRenderTerminalImage.mockReturnValueOnce({ - kind: 'kitty', - sequence: '\x1b_Gpayload\x1b\\', - placeholder: { - color: '#00002a', - imageId: 42, - lines: ['placeholder'], + const { lastFrame } = renderImage( + { + kind: 'kitty', + sequence: '\x1b_Gpayload\x1b\\', + placeholder: { + color: '#00002a', + imageId: 42, + lines: ['placeholder'], + }, }, - }); - - const image = { - type: 'terminal_image' as const, - filePath: '/workspace/chart.png', - mimeType: 'image/png' as const, - }; - const { lastFrame } = render( - - - {(item) => ( - - )} - - , + writeRaw, ); await vi.waitFor(() => { @@ -69,6 +82,23 @@ describe('TerminalImage', () => { expect(lastFrame()).toContain('placeholder'); }); + it('renders chafa ansi output', () => { + const { lastFrame } = renderImage({ kind: 'ansi', lines: ['▀▀', '▄▄'] }); + + expect(lastFrame()).toContain('▀▀'); + expect(lastFrame()).toContain('▄▄'); + }); + + it('shows a readable fallback when no renderer is available', () => { + const { lastFrame } = renderImage({ + kind: 'unavailable', + reason: 'chafa is not installed', + }); + + expect(lastFrame()).toContain('chafa is not installed'); + expect(lastFrame()).toContain('chart.png'); + }); + it('refuses restored paths outside the current workspace', () => { const { lastFrame } = render( { ).toBe(false); expect( getTerminalImageRenderSupport( - imagePath, { PATH: tempDir, TERM: 'xterm-256color', @@ -203,7 +202,6 @@ describe('terminalImageRenderer', () => { }); expect( getTerminalImageRenderSupport( - imagePath, { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', @@ -231,9 +229,7 @@ describe('terminalImageRenderer', () => { expect(result.kind === 'unavailable' && result.reason).toContain( 'chafa is not installed', ); - expect( - getTerminalImageRenderSupport(imagePath, { PATH: tempDir }, true), - ).toEqual({ + expect(getTerminalImageRenderSupport({ PATH: tempDir }, true)).toEqual({ available: false, reason: 'No compatible native image protocol was detected, and chafa is not installed.', diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index 700bd03e964..40fcc79d5b9 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -6,6 +6,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; +import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { MAX_TERMINAL_IMAGE_BYTES, @@ -66,7 +67,6 @@ export function supportsKittyImageProtocol( } export function getTerminalImageRenderSupport( - filePath: string, env: NodeJS.ProcessEnv = process.env, stdoutIsTTY = process.stdout.isTTY, ): TerminalImageRenderSupport { @@ -74,10 +74,17 @@ export function getTerminalImageRenderSupport( return { available: true }; } - const result = renderWithChafa(filePath, { widthCells: 4, rows: 4 }, env); - return result.kind === 'ansi' + // Detect chafa with a PATH lookup instead of rendering the user's image: + // this runs during display_image execution and must never spawn a + // synchronous subprocess. A render failure still surfaces later, as a + // fallback notice, when renderTerminalImage actually runs chafa. + return findChafa(env) ? { available: true } - : { available: false, reason: result.reason }; + : { + available: false, + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }; } export function renderTerminalImage({ @@ -242,3 +249,21 @@ function renderWithChafa( }; } } + +function findChafa(env: NodeJS.ProcessEnv): boolean { + const extensions = + process.platform === 'win32' + ? (env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean) + : ['']; + for (const dir of (env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) { + for (const extension of extensions) { + try { + fs.accessSync(path.join(dir, `chafa${extension}`), fs.constants.X_OK); + return true; + } catch { + // Not executable at this PATH entry; keep searching. + } + } + } + return false; +} diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 992b751d9e1..6ee65c1eed7 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3880,18 +3880,14 @@ describe('Server Config (config.ts)', () => { terminalImageRenderSupportProvider: provider, }); - await expect( - config.getTerminalImageRenderSupport('/workspace/image.png'), - ).resolves.toEqual({ + await expect(config.getTerminalImageRenderSupport()).resolves.toEqual({ available: false, reason: 'renderer unavailable', }); - expect(provider).toHaveBeenCalledWith('/workspace/image.png'); + expect(provider).toHaveBeenCalledWith(); await expect( - new Config(baseParams).getTerminalImageRenderSupport( - '/workspace/image.png', - ), + new Config(baseParams).getTerminalImageRenderSupport(), ).resolves.toEqual({ available: false, reason: 'No terminal image renderer is configured.', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1dc9836f1a0..ad6b5880c69 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -962,9 +962,7 @@ export interface ConfigParameters { * Names returned must be lower-cased; consumers compare case-insensitively. */ disabledSkillNamesProvider?: () => ReadonlySet; - terminalImageRenderSupportProvider?: ( - filePath: string, - ) => Promise; + terminalImageRenderSupportProvider?: () => Promise; /** * Additional directories to scan for skills (SKILL.md files). * Sourced from `settings.skills.directories`. Paths are raw @@ -1793,7 +1791,7 @@ export class Config { | (() => ReadonlySet) | null; private readonly terminalImageRenderSupportProvider: - | ((filePath: string) => Promise) + | (() => Promise) | null; private readonly customSkillDirs: readonly string[]; // `disabledTools` is set at construction @@ -7045,11 +7043,9 @@ export class Config { return this.interactive; } - async getTerminalImageRenderSupport( - filePath: string, - ): Promise { + async getTerminalImageRenderSupport(): Promise { return this.terminalImageRenderSupportProvider - ? this.terminalImageRenderSupportProvider(filePath) + ? this.terminalImageRenderSupportProvider() : { available: false, reason: 'No terminal image renderer is configured.', diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index ea7ed0fcb3c..315dd6220e0 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1662,7 +1662,11 @@ class AgentToolInvocation extends BaseToolInvocation { // ask_user_question restriction (buildForkExecutionAllowlist) with the // display_image restriction (resolveForkExecutionAllowedTools): the fork // keeps the parent's display_image declaration for cache parity but must - // not execute it. + // not execute it. When fork_tools is omitted the fork otherwise has + // unrestricted tools, so no notice is announced here even though + // display_image is still execution-blocked via toolConfig + // .executionAllowedTools below; announcing "every tool minus one" would be + // prompt noise rather than a meaningful restriction. const requestedExecutionAllowedTools = this.params.fork_tools === undefined ? undefined diff --git a/packages/core/src/tools/display-image.ts b/packages/core/src/tools/display-image.ts index f8c3a5e684f..deb75cbde1b 100644 --- a/packages/core/src/tools/display-image.ts +++ b/packages/core/src/tools/display-image.ts @@ -101,11 +101,14 @@ class DisplayImageInvocation extends BaseToolInvocation< let fileHandle: Awaited> | undefined; try { fileHandle = await fs.open(filePath, 'r'); - const header = Buffer.alloc(8); + // Read the full 24-byte PNG header (signature + IHDR width/height) so a + // truncated file is rejected here, before the renderer support check + // reports success. A valid PNG is always at least 24 bytes. + const header = Buffer.alloc(24); const { bytesRead } = await fileHandle.read(header, 0, header.length, 0); if ( bytesRead !== header.length || - header.toString('hex') !== PNG_SIGNATURE + header.subarray(0, 8).toString('hex') !== PNG_SIGNATURE ) { return errorResult( `Only PNG images are supported by display_image: ${filePath}`, @@ -123,8 +126,7 @@ class DisplayImageInvocation extends BaseToolInvocation< await fileHandle?.close(); } - const renderSupport = - await this.config.getTerminalImageRenderSupport(filePath); + const renderSupport = await this.config.getTerminalImageRenderSupport(); if (!renderSupport.available) { return errorResult( `The image was not displayed: ${renderSupport.reason}`, From 0546f1da1015f41f5e25c0270cfdf86af3030e78 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 31 Jul 2026 18:56:25 +0000 Subject: [PATCH 07/15] fix(web-shell): add display_image to tool display contract (#8217) --- packages/web-shell/client/components/messages/toolFormatting.ts | 1 + packages/web-shell/client/i18n.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index d84f40fc127..e449b9f6990 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -58,6 +58,7 @@ export const TOOL_DISPLAY_NAMES: Record = { record_artifact: 'RecordArtifact', web_search: 'WebSearch', image_gen: 'ImageGen', + display_image: 'DisplayImage', bash: 'Shell', shell: 'Shell Command', read: 'ReadFile', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 43de403b4e8..6c22965d8ef 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2729,6 +2729,7 @@ const ZH: Messages = { 'toolName.artifact': '制品', 'toolName.record_artifact': '记录制品', 'toolName.image_gen': '生成图片', + 'toolName.display_image': '显示图片', // web-shell-only wire aliases (see TOOL_DISPLAY_NAMES in toolFormatting.ts) 'toolName.bash': '运行命令', 'toolName.shell': 'Shell 命令', From acd1ec761d7fe5a323f4579b8f2e4505642f27d0 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:13:08 +0800 Subject: [PATCH 08/15] test(cli): cover display image validation edges --- docs/design/tui-display-image-tool.md | 13 +++++++------ .../src/ui/utils/terminal-image-renderer.test.ts | 6 ++++++ packages/core/src/tools/display-image.test.ts | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md index ac9083854a5..3f49bbd3e0c 100644 --- a/docs/design/tui-display-image-tool.md +++ b/docs/design/tui-display-image-tool.md @@ -22,12 +22,13 @@ display value: ``` Before returning success, the tool asks a CLI-injected provider whether the -current terminal has a working renderer. Native protocols pass immediately; -the fallback performs a small `chafa` render using the selected file. If no -renderer is available, the tool returns an execution error so both the TUI and -the model know that the image was not displayed. `llmContent` tells the model -to use `read_file` if it needs to inspect the image. Image bytes and terminal -escape sequences never enter model history or the persisted display value. +current terminal has an available renderer. Native protocols pass immediately; +the fallback checks that `chafa` is available on `PATH` without rendering the +selected file. If no renderer is available, the tool returns an execution error +so both the TUI and the model know that the image was not displayed. +`llmContent` tells the model to use `read_file` if it needs to inspect the image. +Image bytes and terminal escape sequences never enter model history or the +persisted display value. `ToolMessage` recognizes the structured value and delegates it to a TUI component. The component revalidates the path against the current workspace diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index c8c30184f70..35f969757fc 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -133,6 +133,12 @@ describe('terminalImageRenderer', () => { true, ), ).toBe(false); + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-kitty', SSH_CLIENT: '10.0.0.1 51234 22' }, + true, + ), + ).toBe(false); expect(supportsKittyImageProtocol({ TERM: 'xterm-kitty' }, false)).toBe( false, ); diff --git a/packages/core/src/tools/display-image.test.ts b/packages/core/src/tools/display-image.test.ts index 4a1021e374d..3c3db2674ea 100644 --- a/packages/core/src/tools/display-image.test.ts +++ b/packages/core/src/tools/display-image.test.ts @@ -112,7 +112,7 @@ describe('DisplayImageTool', () => { it('rejects non-PNG and oversized files', async () => { const textPath = path.join(workspace, 'not-an-image.png'); - await fs.writeFile(textPath, 'not a png'); + await fs.writeFile(textPath, Buffer.from(`GIF89a${'x'.repeat(32)}`)); const nonPng = await tool .build({ file_path: textPath }) .execute(new AbortController().signal); From 1515975f10ce511547e87055ddebfe97c0c85bf2 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 1 Aug 2026 01:08:42 +0000 Subject: [PATCH 09/15] fix(cli): harden chafa executable lookup (#8217) Resolve chafa through the mermaid renderer's hardened findExecutable and spawn the resolved path, so a project-local node_modules/.bin/chafa is never executed unless the user opted in. display_image is a non-prompting Read tool, so the previous bare-name PATH resolution allowed arbitrary code execution from a malicious repository. Also add review-requested coverage: isTerminalImageDisplay routing, the truncated-PNG rejection branch, the chafa stderr fallback, and the Kitty re-emit dedup guard. --- .../src/ui/components/TerminalImage.test.tsx | 38 +++++++++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 4 +- .../ui/utils/terminal-image-renderer.test.ts | 83 +++++++++++++++++++ .../src/ui/utils/terminal-image-renderer.ts | 46 +++++----- packages/core/src/tools/display-image.test.ts | 11 +++ packages/core/src/tools/tools.test.ts | 53 +++++++++++- 6 files changed, 205 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/ui/components/TerminalImage.test.tsx b/packages/cli/src/ui/components/TerminalImage.test.tsx index 78941ffaabf..bb95c464de8 100644 --- a/packages/cli/src/ui/components/TerminalImage.test.tsx +++ b/packages/cli/src/ui/components/TerminalImage.test.tsx @@ -115,4 +115,42 @@ describe('TerminalImage', () => { expect(lastFrame()).toContain('outside the current workspace'); expect(mockedRenderTerminalImage).not.toHaveBeenCalled(); }); + + it('does not re-emit the Kitty sequence when the emit effect re-runs', async () => { + mockedRenderTerminalImage.mockReturnValue({ + kind: 'kitty', + sequence: '\x1b_Gpayload\x1b\\', + placeholder: { + color: '#00002a', + imageId: 42, + lines: ['placeholder'], + }, + }); + + const renderWith = (writer: (...args: unknown[]) => void) => ( + + + + ); + + const firstWriteRaw = vi.fn(); + const { rerender } = render(renderWith(firstWriteRaw)); + + await vi.waitFor(() => { + expect(firstWriteRaw).toHaveBeenCalledWith('\x1b_Gpayload\x1b\\'); + }); + expect(firstWriteRaw).toHaveBeenCalledTimes(1); + + const secondWriteRaw = vi.fn(); + rerender(renderWith(secondWriteRaw)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(secondWriteRaw).not.toHaveBeenCalled(); + expect(firstWriteRaw).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 0f9fdbd9199..1d8b775e1f9 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -859,7 +859,7 @@ function findMmdc(env: NodeJS.ProcessEnv): string | null { return null; } -function findExecutable( +export function findExecutable( command: string, env: NodeJS.ProcessEnv, ): string | null { @@ -1049,7 +1049,7 @@ async function renderPngWithMmdcAsync( } } -function shouldRunThroughShell(command: string): boolean { +export function shouldRunThroughShell(command: string): boolean { return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command); } diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 35f969757fc..24efc4c0cb5 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -219,6 +219,89 @@ describe('terminalImageRenderer', () => { }, ); + it.runIf(process.platform !== 'win32')( + 'does not trust a project-local node_modules/.bin/chafa', + async () => { + const localBin = path.join(tempDir, 'node_modules', '.bin'); + await fs.mkdir(localBin, { recursive: true }); + const chafaPath = path.join(localBin, 'chafa'); + await fs.writeFile( + chafaPath, + '#!/usr/bin/env node\nprocess.stdout.write("HACKED\\n");\n', + ); + await fs.chmod(chafaPath, 0o755); + + expect( + getTerminalImageRenderSupport( + { + PATH: localBin, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + true, + ), + ).toEqual({ + available: false, + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }); + + const result = renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { + PATH: localBin, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + stdoutIsTTY: true, + }); + expect(result).toEqual({ + kind: 'unavailable', + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }); + }, + ); + + it.runIf(process.platform !== 'win32')( + 'surfaces chafa stderr when rendering fails', + async () => { + const binDir = path.join(tempDir, 'bin'); + await fs.mkdir(binDir); + const chafaPath = path.join(binDir, 'chafa'); + await fs.writeFile( + chafaPath, + '#!/usr/bin/env node\nprocess.stderr.write("libpng: invalid IHDR data\\n");\nprocess.exit(1);\n', + ); + await fs.chmod(chafaPath, 0o755); + + const result = renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + stdoutIsTTY: true, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'libpng: invalid IHDR data', + ); + }, + ); + it('returns a readable fallback when chafa is unavailable', async () => { const result = await renderTerminalImage({ display: { diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index 40fcc79d5b9..e5c0e5d1d07 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -6,7 +6,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; -import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { MAX_TERMINAL_IMAGE_BYTES, @@ -17,7 +16,9 @@ import { buildKittyPlaceholder, createRendererChildEnv, encodeKittyVirtualImage, + findExecutable, readPngSize, + shouldRunThroughShell, type KittyImagePlaceholder, } from './mermaidImageRenderer.js'; @@ -78,7 +79,7 @@ export function getTerminalImageRenderSupport( // this runs during display_image execution and must never spawn a // synchronous subprocess. A render failure still surfaces later, as a // fallback notice, when renderTerminalImage actually runs chafa. - return findChafa(env) + return findExecutable('chafa', env) ? { available: true } : { available: false, @@ -212,9 +213,20 @@ function renderWithChafa( shape: { widthCells: number; rows: number }, env: NodeJS.ProcessEnv, ): Extract { + // Resolve chafa through the hardened lookup so a project-local + // node_modules/.bin/chafa is never executed unless the user opted in, then + // spawn the resolved path rather than a bare name resolved off PATH. + const chafaPath = findExecutable('chafa', env); + if (!chafaPath) { + return { + kind: 'unavailable', + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }; + } try { const stdout = execFileSync( - 'chafa', + chafaPath, [ '--animate=off', '--format=symbols', @@ -225,6 +237,7 @@ function renderWithChafa( { encoding: 'utf8', env: createRendererChildEnv(env), + shell: shouldRunThroughShell(chafaPath), maxBuffer: CHAFA_MAX_OUTPUT_BYTES, timeout: CHAFA_TIMEOUT_MS, }, @@ -235,35 +248,14 @@ function renderWithChafa( : { kind: 'unavailable', reason: 'chafa produced no output.' }; } catch (error) { const execError = error as Error & { - code?: string | number; stderr?: Buffer | string; }; return { kind: 'unavailable', reason: - execError.code === 'ENOENT' - ? 'No compatible native image protocol was detected, and chafa is not installed.' - : String(execError.stderr ?? '').trim() || - execError.message || - 'chafa could not render the image.', + String(execError.stderr ?? '').trim() || + execError.message || + 'chafa could not render the image.', }; } } - -function findChafa(env: NodeJS.ProcessEnv): boolean { - const extensions = - process.platform === 'win32' - ? (env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean) - : ['']; - for (const dir of (env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) { - for (const extension of extensions) { - try { - fs.accessSync(path.join(dir, `chafa${extension}`), fs.constants.X_OK); - return true; - } catch { - // Not executable at this PATH entry; keep searching. - } - } - } - return false; -} diff --git a/packages/core/src/tools/display-image.test.ts b/packages/core/src/tools/display-image.test.ts index 3c3db2674ea..ef6acad617e 100644 --- a/packages/core/src/tools/display-image.test.ts +++ b/packages/core/src/tools/display-image.test.ts @@ -127,4 +127,15 @@ describe('DisplayImageTool', () => { .execute(new AbortController().signal); expect(oversized.error?.type).toBe(ToolErrorType.FILE_TOO_LARGE); }); + + it('rejects a truncated PNG header', async () => { + const truncatedPath = path.join(workspace, 'truncated.png'); + await fs.writeFile(truncatedPath, Buffer.from('89504e470d0a1a0a', 'hex')); + + const result = await tool + .build({ file_path: truncatedPath }) + .execute(new AbortController().signal); + + expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + }); }); diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts index 244642e8305..1948566e9b4 100644 --- a/packages/core/src/tools/tools.test.ts +++ b/packages/core/src/tools/tools.test.ts @@ -7,7 +7,12 @@ import { describe, it, expect, vi } from 'vitest'; import type { ToolInvocation, ToolResult } from './tools.js'; import type { PermissionDecision } from '../permissions/types.js'; -import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js'; +import { + DeclarativeTool, + hasCycleInSchema, + isTerminalImageDisplay, + Kind, +} from './tools.js'; import { ToolErrorType } from './tool-error.js'; class TestToolInvocation implements ToolInvocation { @@ -233,3 +238,49 @@ describe('hasCycleInSchema', () => { expect(hasCycleInSchema({})).toBe(false); }); }); + +describe('isTerminalImageDisplay', () => { + it('accepts a well-formed terminal image display', () => { + expect( + isTerminalImageDisplay({ + type: 'terminal_image', + filePath: '/workspace/chart.png', + mimeType: 'image/png', + }), + ).toBe(true); + }); + + it('rejects null, wrong type, non-string filePath, and wrong mimeType', () => { + expect(isTerminalImageDisplay(null)).toBe(false); + expect(isTerminalImageDisplay(undefined)).toBe(false); + expect(isTerminalImageDisplay('terminal_image')).toBe(false); + expect(isTerminalImageDisplay({})).toBe(false); + expect( + isTerminalImageDisplay({ + type: 'something_else', + filePath: '/workspace/chart.png', + mimeType: 'image/png', + }), + ).toBe(false); + expect( + isTerminalImageDisplay({ + type: 'terminal_image', + filePath: 123, + mimeType: 'image/png', + }), + ).toBe(false); + expect( + isTerminalImageDisplay({ + type: 'terminal_image', + filePath: '/workspace/chart.png', + }), + ).toBe(false); + expect( + isTerminalImageDisplay({ + type: 'terminal_image', + filePath: '/workspace/chart.png', + mimeType: 'image/jpeg', + }), + ).toBe(false); + }); +}); From 10337397dbf2833f3fe694fa8310c075cf6a93b6 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Sat, 1 Aug 2026 06:56:41 +0000 Subject: [PATCH 10/15] fix(cli): address TUI image display review feedback (#8217) - Cache rendered terminal images (bounded LRU keyed on path, mtime, size, shape, and renderer) so a terminal resize or a restored session no longer re-reads the file or re-spawns chafa for every visible image. - Reject cmd.exe metacharacters in the model-supplied path before spawning chafa through a shell, closing a command-injection surface on Windows .cmd/.bat shims. - Bound a failing chafa's stderr to a capped first line before rendering it into permanent scrollback. - Enforce the main-agent-only display_image ban inside the tool execution (isInForkExecution) and fail resolveForkExecutionAllowedTools closed when display_image is advertised without a concrete allowlist. - Move the fileName computation to its use site, drop misleading awaits on the synchronous renderer, and add Kitty/Ghostty detection plus renderer cache, truncation, and shell-metacharacter tests. --- .../cli/src/ui/components/TerminalImage.tsx | 2 +- .../ui/utils/terminal-image-renderer.test.ts | 159 ++++++++++++- .../src/ui/utils/terminal-image-renderer.ts | 209 ++++++++++++++++-- .../src/tools/agent/fork-subagent.test.ts | 6 + .../core/src/tools/agent/fork-subagent.ts | 10 +- packages/core/src/tools/display-image.test.ts | 15 ++ packages/core/src/tools/display-image.ts | 10 + 7 files changed, 379 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/ui/components/TerminalImage.tsx b/packages/cli/src/ui/components/TerminalImage.tsx index 4ee7318f557..d530a86b3e4 100644 --- a/packages/cli/src/ui/components/TerminalImage.tsx +++ b/packages/cli/src/ui/components/TerminalImage.tsx @@ -63,7 +63,6 @@ export const TerminalImage: React.FC = ({ process.nextTick(() => writeRaw(kittySequence)); }, [kittySequence, writeRaw]); - const fileName = sanitizeMultilineForDisplay(path.basename(filePath)); if (!safePath) { return ( @@ -73,6 +72,7 @@ export const TerminalImage: React.FC = ({ } if (!result) return null; if (result.kind === 'unavailable') { + const fileName = sanitizeMultilineForDisplay(path.basename(filePath)); return ( {fileName}: {sanitizeTerminalText(result.reason)} diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 24efc4c0cb5..2ed3d24f34c 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + containsCmdShellMetacharacters, getTerminalImageRenderSupport, renderTerminalImage, supportsKittyImageProtocol, @@ -60,8 +61,8 @@ describe('terminalImageRenderer', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it('renders a Kitty virtual image with placeholder rows', async () => { - const result = await renderTerminalImage({ + it('renders a Kitty virtual image with placeholder rows', () => { + const result = renderTerminalImage({ display: { type: 'terminal_image', filePath: imagePath, @@ -144,6 +145,24 @@ describe('terminalImageRenderer', () => { ); }); + it('detects Kitty and Ghostty native placement', () => { + expect( + supportsKittyImageProtocol( + { KITTY_WINDOW_ID: '1', TERM: 'xterm-256color' }, + true, + ), + ).toBe(true); + expect(supportsKittyImageProtocol({ TERM_PROGRAM: 'ghostty' }, true)).toBe( + true, + ); + expect( + supportsKittyImageProtocol( + { TERM_PROGRAM: 'ghostty', TMUX: '/tmp/tmux' }, + true, + ), + ).toBe(false); + }); + it('does not use Kitty Unicode placeholders in Warp', () => { expect( supportsKittyImageProtocol( @@ -186,7 +205,7 @@ describe('terminalImageRenderer', () => { await fs.chmod(chafaPath, 0o755); await fs.writeFile(imagePath, pngWithSize(1600, 800)); - const result = await renderTerminalImage({ + const result = renderTerminalImage({ display: { type: 'terminal_image', filePath: imagePath, @@ -302,8 +321,100 @@ describe('terminalImageRenderer', () => { }, ); - it('returns a readable fallback when chafa is unavailable', async () => { - const result = await renderTerminalImage({ + it.runIf(process.platform !== 'win32')( + 'bounds a verbose chafa failure to a capped first line', + async () => { + const binDir = path.join(tempDir, 'bin'); + await fs.mkdir(binDir); + const chafaPath = path.join(binDir, 'chafa'); + await fs.writeFile( + chafaPath, + '#!/usr/bin/env node\nprocess.stderr.write("E".repeat(300) + "\\nsecond line\\n");\nprocess.exit(1);\n', + ); + await fs.chmod(chafaPath, 0o755); + + const result = renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + stdoutIsTTY: true, + }); + + expect(result.kind).toBe('unavailable'); + if (result.kind !== 'unavailable') return; + expect(result.reason).toBe(`${'E'.repeat(200)}…`); + expect(result.reason).not.toContain('\n'); + expect(result.reason).not.toContain('second line'); + }, + ); + + it.runIf(process.platform !== 'win32')( + 'caches a render so resize and restore do not re-spawn chafa', + async () => { + const binDir = path.join(tempDir, 'bin'); + await fs.mkdir(binDir); + const chafaPath = path.join(binDir, 'chafa'); + await fs.writeFile( + chafaPath, + '#!/usr/bin/env node\nconst fs = require("fs");\nconst path = require("path");\nfs.appendFileSync(path.join(__dirname, "count.txt"), "x\\n");\nprocess.stdout.write(`${process.argv.find((arg) => arg.startsWith("--size="))}\\n`);\n', + ); + await fs.chmod(chafaPath, 0o755); + await fs.writeFile(imagePath, pngWithSize(1600, 800)); + + const options = { + display: { + type: 'terminal_image' as const, + filePath: imagePath, + mimeType: 'image/png' as const, + }, + contentWidth: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + stdoutIsTTY: true, + }; + const spawnCount = async () => { + try { + const raw = await fs.readFile(path.join(binDir, 'count.txt'), 'utf8'); + return raw.split('x').length - 1; + } catch { + return 0; + } + }; + + const first = renderTerminalImage(options); + expect(first.kind).toBe('ansi'); + const second = renderTerminalImage(options); + expect(second).toEqual(first); + expect(await spawnCount()).toBe(1); + + // A different shape misses the cache and re-spawns the renderer. + await fs.writeFile(imagePath, pngWithSize(800, 1600)); + const third = renderTerminalImage(options); + expect(third.kind).toBe('ansi'); + expect(await spawnCount()).toBe(2); + + // The same shape with a newer mtime invalidates the cached entry. + const future = new Date(Date.now() + 10_000); + await fs.utimes(imagePath, future, future); + const fourth = renderTerminalImage(options); + expect(fourth.kind).toBe('ansi'); + expect(await spawnCount()).toBe(3); + }, + ); + + it('returns a readable fallback when chafa is unavailable', () => { + const result = renderTerminalImage({ display: { type: 'terminal_image', filePath: imagePath, @@ -326,7 +437,7 @@ describe('terminalImageRenderer', () => { }); it('rejects missing and invalid PNG files during restored rendering', async () => { - const missing = await renderTerminalImage({ + const missing = renderTerminalImage({ display: { type: 'terminal_image', filePath: path.join(tempDir, 'missing.png'), @@ -339,7 +450,7 @@ describe('terminalImageRenderer', () => { expect(missing.kind).toBe('unavailable'); await fs.writeFile(imagePath, 'not a png'); - const invalid = await renderTerminalImage({ + const invalid = renderTerminalImage({ display: { type: 'terminal_image', filePath: imagePath, @@ -358,7 +469,7 @@ describe('terminalImageRenderer', () => { const handle = await fs.open(oversizedPath, 'w'); await handle.truncate(8 * 1024 * 1024 + 1); await handle.close(); - const oversized = await renderTerminalImage({ + const oversized = renderTerminalImage({ display: { type: 'terminal_image', filePath: oversizedPath, @@ -374,3 +485,35 @@ describe('terminalImageRenderer', () => { ); }); }); + +describe('containsCmdShellMetacharacters', () => { + it('flags cmd.exe metacharacters in a model-supplied path', () => { + for (const dangerous of [ + 'chart & whoami.png', + 'a|b.png', + 'ab.png', + 'a^b.png', + 'a%PATH%.png', + 'a"b.png', + 'a!b.png', + 'a(b).png', + 'a\nb.png', + 'a\rb.png', + ]) { + expect(containsCmdShellMetacharacters(dangerous)).toBe(true); + } + }); + + it('accepts ordinary image paths', () => { + for (const safe of [ + '/workspace/chart.png', + '/workspace/reports/q3-summary.png', + '/workspace/img_2026-08-01.png', + '/workspace/my image.png', + 'C:\\Users\\me\\Pictures\\vacation.png', + ]) { + expect(containsCmdShellMetacharacters(safe)).toBe(false); + } + }); +}); diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index e5c0e5d1d07..60dcbd7a3a9 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -28,6 +28,21 @@ const MAX_PREVIEW_WIDTH_CELLS = 72; const MAX_PREVIEW_ROWS = 24; const ESTIMATED_CELL_WIDTH_PX = 8; const ESTIMATED_CELL_HEIGHT_PX = 16; +const MAX_REASON_CHARS = 200; +// cmd.exe treats these as command separators / metacharacters. With shell:true +// Node forwards arguments unquoted, so a model-chosen path containing any of +// them is a command-injection vector through a .cmd/.bat chafa shim. +const CMD_SHELL_METACHARACTERS = /[&|<>^%"!()\n\r]/; + +// Completed tool rows enter Ink's append-only Static region immediately, so +// rendering must stay synchronous. A terminal resize or a restored session +// re-renders every visible image, which would otherwise re-read the file and +// re-spawn chafa for each one. This cache makes those repeats cheap; it mirrors +// the bounded LRU the Mermaid renderer keeps for the same reason. +const RENDER_CACHE_LIMIT = 40; +const RENDER_CACHE_BYTE_LIMIT = 32 * 1024 * 1024; +const renderCache = new Map(); +let renderCacheBytes = 0; export type TerminalImageRenderResult = | { @@ -88,6 +103,10 @@ export function getTerminalImageRenderSupport( }; } +export function containsCmdShellMetacharacters(filePath: string): boolean { + return CMD_SHELL_METACHARACTERS.test(filePath); +} + export function renderTerminalImage({ display, contentWidth, @@ -119,6 +138,30 @@ export function renderTerminalImage({ }; } + // Read only the 24-byte PNG header to size the preview and build the cache + // key, so a cache hit never re-reads the full file or re-spawns chafa. + const headerSize = readPngHeaderSize(display.filePath); + if (!headerSize) { + return { kind: 'unavailable', reason: 'Image is not a valid PNG.' }; + } + + const shape = fitImageToTerminal( + headerSize, + contentWidth, + availableTerminalHeight, + ); + const useKitty = supportsKittyImageProtocol(env, stdoutIsTTY); + const chafaPath = useKitty ? null : findExecutable('chafa', env); + const cacheKey = createRenderCacheKey( + display.filePath, + stat, + shape, + useKitty, + chafaPath, + ); + const cached = getCachedRenderResult(cacheKey); + if (cached) return cached; + let png: Buffer; try { png = fs.readFileSync(display.filePath); @@ -136,27 +179,118 @@ export function renderTerminalImage({ reason: `Image exceeds the ${MAX_TERMINAL_IMAGE_BYTES} byte display limit.`, }; } - const size = readPngSize(png); - if (!size) { - return { kind: 'unavailable', reason: 'Image is not a valid PNG.' }; + + const result: TerminalImageRenderResult = useKitty + ? renderKitty(png, shape) + : renderWithChafa(display.filePath, shape, env, chafaPath); + if (result.kind !== 'unavailable') { + rememberRenderResult(cacheKey, result); } + return result; +} - const shape = fitImageToTerminal(size, contentWidth, availableTerminalHeight); - if (supportsKittyImageProtocol(env, stdoutIsTTY)) { - const imageId = createImageId(png, shape); - return { - kind: 'kitty', - sequence: encodeKittyVirtualImage( - png, - imageId, - shape.widthCells, - shape.rows, - ), - placeholder: buildKittyPlaceholder(imageId, shape.widthCells, shape.rows), - }; +function readPngHeaderSize( + filePath: string, +): { width: number; height: number } | null { + let fd: number | undefined; + try { + fd = fs.openSync(filePath, 'r'); + const header = Buffer.alloc(24); + const bytesRead = fs.readSync(fd, header, 0, header.length, 0); + return bytesRead === header.length ? readPngSize(header) : null; + } catch { + return null; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // Closing a read-only handle should not fail the render. + } + } } +} - return renderWithChafa(display.filePath, shape, env); +function createRenderCacheKey( + filePath: string, + stat: fs.Stats, + shape: { widthCells: number; rows: number }, + useKitty: boolean, + chafaPath: string | null, +): string { + return [ + filePath, + stat.mtimeMs, + stat.size, + shape.widthCells, + shape.rows, + useKitty ? 'kitty' : (chafaPath ?? 'none'), + ].join('\0'); +} + +function getCachedRenderResult( + key: string, +): TerminalImageRenderResult | undefined { + const cached = renderCache.get(key); + if (cached) { + renderCache.delete(key); + renderCache.set(key, cached); + } + return cached; +} + +function rememberRenderResult( + key: string, + result: TerminalImageRenderResult, +): void { + const bytes = estimateRenderResultBytes(result); + if (bytes > RENDER_CACHE_BYTE_LIMIT) { + renderCache.delete(key); + return; + } + + const existing = renderCache.get(key); + if (existing) { + renderCacheBytes -= estimateRenderResultBytes(existing); + } + renderCache.set(key, result); + renderCacheBytes += bytes; + while ( + renderCache.size > RENDER_CACHE_LIMIT || + renderCacheBytes > RENDER_CACHE_BYTE_LIMIT + ) { + const oldest = renderCache.keys().next().value; + if (!oldest) break; + const oldestResult = renderCache.get(oldest); + if (oldestResult) { + renderCacheBytes -= estimateRenderResultBytes(oldestResult); + } + renderCache.delete(oldest); + } +} + +function estimateRenderResultBytes(result: TerminalImageRenderResult): number { + switch (result.kind) { + case 'kitty': + return ( + Buffer.byteLength(result.sequence, 'utf8') + + result.placeholder.lines.reduce( + (total, line) => total + Buffer.byteLength(line, 'utf8'), + 0, + ) + ); + case 'ansi': + return result.lines.reduce( + (total, line) => total + Buffer.byteLength(line, 'utf8'), + 0, + ); + case 'unavailable': + return Buffer.byteLength(result.reason, 'utf8'); + default: { + const exhaustive: never = result; + return exhaustive; + } + } } function fitImageToTerminal( @@ -208,15 +342,32 @@ function createImageId( return id === 0 ? 1 : id; } +function renderKitty( + png: Buffer, + shape: { widthCells: number; rows: number }, +): Extract { + const imageId = createImageId(png, shape); + return { + kind: 'kitty', + sequence: encodeKittyVirtualImage( + png, + imageId, + shape.widthCells, + shape.rows, + ), + placeholder: buildKittyPlaceholder(imageId, shape.widthCells, shape.rows), + }; +} + function renderWithChafa( filePath: string, shape: { widthCells: number; rows: number }, env: NodeJS.ProcessEnv, + chafaPath: string | null, ): Extract { // Resolve chafa through the hardened lookup so a project-local // node_modules/.bin/chafa is never executed unless the user opted in, then // spawn the resolved path rather than a bare name resolved off PATH. - const chafaPath = findExecutable('chafa', env); if (!chafaPath) { return { kind: 'unavailable', @@ -224,6 +375,14 @@ function renderWithChafa( 'No compatible native image protocol was detected, and chafa is not installed.', }; } + const useShell = shouldRunThroughShell(chafaPath); + if (useShell && containsCmdShellMetacharacters(filePath)) { + return { + kind: 'unavailable', + reason: + 'Image path contains characters that cannot be safely passed to the renderer on this platform.', + }; + } try { const stdout = execFileSync( chafaPath, @@ -237,7 +396,7 @@ function renderWithChafa( { encoding: 'utf8', env: createRendererChildEnv(env), - shell: shouldRunThroughShell(chafaPath), + shell: useShell, maxBuffer: CHAFA_MAX_OUTPUT_BYTES, timeout: CHAFA_TIMEOUT_MS, }, @@ -250,12 +409,18 @@ function renderWithChafa( const execError = error as Error & { stderr?: Buffer | string; }; + const stderr = firstLineBounded(String(execError.stderr ?? '').trim()); return { kind: 'unavailable', reason: - String(execError.stderr ?? '').trim() || - execError.message || - 'chafa could not render the image.', + stderr || execError.message || 'chafa could not render the image.', }; } } + +function firstLineBounded(text: string): string { + const firstLine = text.split(/\r?\n/, 1)[0]?.trim() ?? ''; + return firstLine.length > MAX_REASON_CHARS + ? `${firstLine.slice(0, MAX_REASON_CHARS)}…` + : firstLine; +} diff --git a/packages/core/src/tools/agent/fork-subagent.test.ts b/packages/core/src/tools/agent/fork-subagent.test.ts index 421bd859bb8..4d88d4a506f 100644 --- a/packages/core/src/tools/agent/fork-subagent.test.ts +++ b/packages/core/src/tools/agent/fork-subagent.test.ts @@ -44,6 +44,12 @@ describe('resolveForkExecutionAllowedTools', () => { ]), ).toEqual([ToolNames.READ_FILE]); }); + + it('fails closed when display_image is advertised without an allowlist', () => { + expect(resolveForkExecutionAllowedTools(parentTools, undefined)).toEqual( + [], + ); + }); }); describe('validateForkToolList', () => { diff --git a/packages/core/src/tools/agent/fork-subagent.ts b/packages/core/src/tools/agent/fork-subagent.ts index 5dcec40e498..9308478a76c 100644 --- a/packages/core/src/tools/agent/fork-subagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -74,7 +74,15 @@ export function resolveForkExecutionAllowedTools( return requestedToolNames ? [...requestedToolNames] : undefined; } - return requestedToolNames?.filter((name) => name !== ToolNames.DISPLAY_IMAGE); + // display_image is main-session-only. "Unrestricted" (undefined) minus + // display_image cannot be written as a finite allowlist, so fail closed to + // deny-all instead of returning undefined — that would hand the fork + // unrestricted execution, including the very tool this strips. Every live + // caller passes a concrete list (buildForkExecutionAllowlist always returns + // an array); DisplayImageInvocation.execute() also enforces this locally. + return ( + requestedToolNames?.filter((name) => name !== ToolNames.DISPLAY_IMAGE) ?? [] + ); } /** diff --git a/packages/core/src/tools/display-image.test.ts b/packages/core/src/tools/display-image.test.ts index ef6acad617e..84fcc2c5670 100644 --- a/packages/core/src/tools/display-image.test.ts +++ b/packages/core/src/tools/display-image.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Config } from '../config/config.js'; +import { runInForkContext } from './agent/fork-subagent.js'; import { ToolErrorType } from './tool-error.js'; import { DisplayImageTool } from './display-image.js'; import { MAX_TERMINAL_IMAGE_BYTES } from './tools.js'; @@ -64,6 +65,20 @@ describe('DisplayImageTool', () => { expect(invocation.toolLocations()).toEqual([{ path: imagePath }]); }); + it('refuses to execute inside a fork subagent', async () => { + const imagePath = path.join(workspace, 'pixel.png'); + await fs.writeFile(imagePath, PNG_1X1); + + const invocation = tool.build({ file_path: imagePath }); + const result = await runInForkContext(() => + invocation.execute(new AbortController().signal), + ); + + expect(result.error?.type).toBe(ToolErrorType.EXECUTION_DENIED); + expect(result.llmContent).toContain('main agent'); + expect(result.returnDisplay).not.toHaveProperty('type', 'terminal_image'); + }); + it('requires an absolute workspace path', () => { expect(() => tool.build({ file_path: 'pixel.png' })).toThrow( /must be absolute/, diff --git a/packages/core/src/tools/display-image.ts b/packages/core/src/tools/display-image.ts index deb75cbde1b..913bbcef95d 100644 --- a/packages/core/src/tools/display-image.ts +++ b/packages/core/src/tools/display-image.ts @@ -8,6 +8,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { Config } from '../config/config.js'; import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js'; +import { isInForkExecution } from './agent/fork-subagent.js'; import { ToolErrorType } from './tool-error.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { @@ -58,6 +59,15 @@ class DisplayImageInvocation extends BaseToolInvocation< async execute(signal: AbortSignal): Promise { signal.throwIfAborted(); + // The fork registry keeps this tool's declaration for prompt-cache parity, + // so the execution ban cannot live in declaration stripping alone. Enforce + // it here so the invariant holds no matter how the allowlist is wired. + if (isInForkExecution()) { + return errorResult( + 'display_image can only be executed by the main agent.', + ToolErrorType.EXECUTION_DENIED, + ); + } const filePath = path.resolve(this.params.file_path); let stat: Awaited>; From 7ede190fc2c2defcf9196e18ee1bfc5deb31ac10 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:08:42 +0800 Subject: [PATCH 11/15] fix(cli): preserve color in chafa image fallback --- packages/cli/src/ui/utils/terminal-image-renderer.test.ts | 4 ++-- packages/cli/src/ui/utils/terminal-image-renderer.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 24efc4c0cb5..47114b76106 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -181,7 +181,7 @@ describe('terminalImageRenderer', () => { const chafaPath = path.join(binDir, 'chafa'); await fs.writeFile( chafaPath, - '#!/usr/bin/env node\nprocess.stdout.write(process.env.TEST_RENDERER_SECRET ? "LEAKED\\n" : `${process.argv.find((arg) => arg.startsWith("--size="))}\\n`);\n', + '#!/usr/bin/env node\nprocess.stdout.write(process.env.TEST_RENDERER_SECRET ? "LEAKED\\n" : `${process.argv.find((arg) => arg.startsWith("--colors="))} ${process.argv.find((arg) => arg.startsWith("--size="))}\\n`);\n', ); await fs.chmod(chafaPath, 0o755); await fs.writeFile(imagePath, pngWithSize(1600, 800)); @@ -204,7 +204,7 @@ describe('terminalImageRenderer', () => { expect(result).toEqual({ kind: 'ansi', - lines: ['--size=20x5'], + lines: ['--colors=256 --size=20x5'], }); expect( getTerminalImageRenderSupport( diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index e5c0e5d1d07..72619db1522 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -229,6 +229,7 @@ function renderWithChafa( chafaPath, [ '--animate=off', + '--colors=256', '--format=symbols', '--symbols=block', `--size=${shape.widthCells}x${shape.rows}`, From 00cf11d749a5129659b374e7e926e92ece97f7a5 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:53:23 +0800 Subject: [PATCH 12/15] feat(cli): render images directly in Warp --- docs/design/tui-display-image-tool.md | 34 +++-- .../src/ui/components/TerminalImage.test.tsx | 28 +++- .../cli/src/ui/components/TerminalImage.tsx | 17 +++ .../src/ui/utils/mermaidImageRenderer.test.ts | 2 +- .../cli/src/ui/utils/mermaidImageRenderer.ts | 5 +- .../ui/utils/terminal-image-renderer.test.ts | 120 ++++++++++----- .../src/ui/utils/terminal-image-renderer.ts | 59 +++++++- patches/ink+7.0.3.patch | 141 ++++++++++++++---- 8 files changed, 318 insertions(+), 88 deletions(-) diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md index 3f49bbd3e0c..e56438d7f1a 100644 --- a/docs/design/tui-display-image-tool.md +++ b/docs/design/tui-display-image-tool.md @@ -35,12 +35,16 @@ component. The component revalidates the path against the current workspace before reading it. Rendering is prepared during the component's first render because completed tool rows enter Ink's append-only `Static` region immediately; an asynchronous state update would be dropped there. -Kitty-compatible terminals use the existing virtual-image and -Unicode-placeholder approach already used for Mermaid diagrams. Other -terminals try `chafa` with symbol output. `chafa` receives only the same -allowlisted environment used by the Mermaid renderer, so API credentials are -not forwarded. If neither path is available, the TUI shows a bounded text -fallback naming the image. +Kitty and Ghostty use the existing virtual-image and Unicode-placeholder +approach already used for Mermaid diagrams. Warp uses the Kitty protocol's +direct `a=T` placement because it supports the base protocol but not the +placeholder extension. The direct command sets `C=1`, so the terminal does not +move Ink's cursor, and an opt-in Ink text node emits the trusted command at the +image's exact position while ordinary text continues to strip terminal control +sequences. Other terminals try `chafa` with symbol output. `chafa` receives +only the same allowlisted environment used by the Mermaid renderer, so API +credentials are not forwarded. If neither path is available, the TUI shows a +bounded text fallback naming the image. Previews are capped at 72 columns and 24 rows, then reduced further to fit the available terminal space. An 8-by-16-pixel terminal cell estimate provides a @@ -58,13 +62,11 @@ Bare mode keeps its existing minimal tool set. ## Compatibility -The first version supports PNG only. Kitty and Ghostty use native image -placement. Warp is intentionally excluded because its Kitty support does not -include the Unicode-placeholder extension used by this renderer. Direct iTerm2 -placement is also excluded because its inline image protocol is -cursor-positioned and the existing asynchronous Ink path already treats it as -unsafe. Warp, iTerm2, tmux, SSH, and terminals without a native path can still -render through `chafa` when it is installed. +The first version supports PNG only. Kitty and Ghostty use virtual native image +placement; Warp uses direct native Kitty placement on macOS and Linux without +Unicode placeholders. Direct iTerm2 placement remains excluded. Warp on +Windows, Warp sessions inside tmux or SSH, iTerm2, and terminals without a +native path render through `chafa` when it is installed. ## Security and persistence @@ -74,5 +76,7 @@ The renderer treats restored `resultDisplay` data as untrusted and repeats this check. Persisted sessions contain only the path and MIME type; if the file no longer exists, restore degrades to a text notice. -Raw terminal output is generated only by the trusted renderer. Model-controlled -strings and file contents are never written directly to the terminal. +Raw terminal output is generated only by the trusted renderer. Ink's direct +terminal-control path is opt-in on the image component, so model-controlled +strings cannot use it. Model-controlled strings and file contents are never +written directly to the terminal. diff --git a/packages/cli/src/ui/components/TerminalImage.test.tsx b/packages/cli/src/ui/components/TerminalImage.test.tsx index bb95c464de8..7cbeab41f33 100644 --- a/packages/cli/src/ui/components/TerminalImage.test.tsx +++ b/packages/cli/src/ui/components/TerminalImage.test.tsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'ink-testing-library'; -import { Static } from 'ink'; +import { Static, Text } from 'ink'; import type { Config } from '@qwen-code/qwen-code-core'; import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; import { @@ -89,6 +89,32 @@ describe('TerminalImage', () => { expect(lastFrame()).toContain('▄▄'); }); + it('emits direct Kitty placement inline and reserves its rows', () => { + const sequence = '\x1b_Ga=T,f=100,q=2,C=1,c=4,r=2;payload\x1b\\'; + const { stdout } = renderImage({ + kind: 'kitty-direct', + sequence, + rows: 2, + }); + + const directFrame = stdout.frames.find((frame) => frame.includes(sequence)); + expect(Buffer.from(directFrame ?? '').toString('hex')).toBe( + Buffer.from(`${sequence}\n\n`).toString('hex'), + ); + }); + + it('keeps terminal controls opt-in for ordinary text', () => { + const sequence = '\x1b_Ga=T,f=100;payload\x1b\\'; + const { stdout } = render( + + {(item) => {item}safe} + , + ); + + expect(stdout.frames.join('')).not.toContain(sequence); + expect(stdout.frames.join('')).toContain('safe'); + }); + it('shows a readable fallback when no renderer is available', () => { const { lastFrame } = renderImage({ kind: 'unavailable', diff --git a/packages/cli/src/ui/components/TerminalImage.tsx b/packages/cli/src/ui/components/TerminalImage.tsx index d530a86b3e4..fda15b32458 100644 --- a/packages/cli/src/ui/components/TerminalImage.tsx +++ b/packages/cli/src/ui/components/TerminalImage.tsx @@ -94,6 +94,23 @@ export const TerminalImage: React.FC = ({ ); } + if (result.kind === 'kitty-direct') { + return ( + + + + {result.sequence} + + + + {Array.from({ length: result.rows - 1 }, (_, index) => ( + + + + ))} + + ); + } return ( { '\u001b]1337;File=inline=1;width=40;height=10;', ); expect(encodeKittyImage(PNG_1X1, 40, 10)).toContain( - '\u001b_Ga=T,f=100,c=40,r=10,', + '\u001b_Ga=T,f=100,q=2,C=1,c=40,r=10,', ); expect(encodeKittyVirtualImage(PNG_1X1, 42, 40, 10)).toContain( '\u001b_Ga=T,f=100,i=42,q=2,U=1,c=40,r=10,', diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 1d8b775e1f9..64585411a1c 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -285,7 +285,10 @@ export function encodeKittyImage( widthCells: number, rows: number, ): string { - return encodeKittyImageCommand(png, `a=T,f=100,c=${widthCells},r=${rows}`); + return encodeKittyImageCommand( + png, + `a=T,f=100,q=2,C=1,c=${widthCells},r=${rows}`, + ); } export function encodeKittyVirtualImage( diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 19ddcf7dd2f..6ce6ce9cb88 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -12,6 +12,7 @@ import { containsCmdShellMetacharacters, getTerminalImageRenderSupport, renderTerminalImage, + supportsKittyDirectImageProtocol, supportsKittyImageProtocol, } from './terminal-image-renderer.js'; @@ -163,34 +164,85 @@ describe('terminalImageRenderer', () => { ).toBe(false); }); - it('does not use Kitty Unicode placeholders in Warp', () => { - expect( - supportsKittyImageProtocol( - { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, - true, - ), - ).toBe(false); - expect( - supportsKittyImageProtocol( - { TERM: 'xterm-kitty', TERM_PROGRAM: 'WarpTerminal' }, - true, - ), - ).toBe(false); - expect( - getTerminalImageRenderSupport( - { - PATH: tempDir, - TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + it.runIf(process.platform !== 'win32')( + 'uses direct Kitty placement without Unicode placeholders in Warp', + () => { + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + true, + ), + ).toBe(false); + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-kitty', TERM_PROGRAM: 'WarpTerminal' }, + true, + ), + ).toBe(false); + expect( + supportsKittyDirectImageProtocol( + { + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + true, + ), + ).toBe(true); + expect( + supportsKittyDirectImageProtocol( + { TERM_PROGRAM: 'WarpTerminal', TMUX: '/tmp/tmux' }, + true, + ), + ).toBe(false); + expect( + supportsKittyDirectImageProtocol( + { TERM_PROGRAM: 'WarpTerminal', SSH_TTY: '/dev/pts/1' }, + true, + ), + ).toBe(false); + expect( + supportsKittyDirectImageProtocol( + { TERM_PROGRAM: 'WarpTerminal', SSH_CLIENT: '10.0.0.1 51234 22' }, + true, + ), + ).toBe(false); + expect( + supportsKittyDirectImageProtocol( + { TERM_PROGRAM: 'WarpTerminal' }, + false, + ), + ).toBe(false); + expect( + getTerminalImageRenderSupport( + { + PATH: tempDir, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', + }, + true, + ), + ).toEqual({ available: true }); + + const result = renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: imagePath, + mimeType: 'image/png', }, - true, - ), - ).toEqual({ - available: false, - reason: - 'No compatible native image protocol was detected, and chafa is not installed.', - }); - }); + contentWidth: 24, + availableTerminalHeight: 12, + env: { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + stdoutIsTTY: true, + }); + + expect(result.kind).toBe('kitty-direct'); + if (result.kind !== 'kitty-direct') return; + expect(result.sequence).toContain('\u001b_Ga=T,f=100,q=2,C=1'); + expect(result.sequence).not.toContain('U=1'); + expect(result.sequence).toContain('c=1,r=1'); + expect(result.rows).toBe(1); + }, + ); it.runIf(process.platform !== 'win32')( 'falls back to chafa symbol output', @@ -215,7 +267,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', TEST_RENDERER_SECRET: 'must-not-reach-chafa', }, stdoutIsTTY: true, @@ -230,7 +282,7 @@ describe('terminalImageRenderer', () => { { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', }, true, ), @@ -255,7 +307,7 @@ describe('terminalImageRenderer', () => { { PATH: localBin, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', }, true, ), @@ -275,7 +327,7 @@ describe('terminalImageRenderer', () => { env: { PATH: localBin, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', }, stdoutIsTTY: true, }); @@ -309,7 +361,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', }, stdoutIsTTY: true, }); @@ -343,7 +395,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', }, stdoutIsTTY: true, }); @@ -379,7 +431,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', + TERM_PROGRAM: 'Apple_Terminal', }, stdoutIsTTY: true, }; diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index 7538051a700..061f46a946c 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -15,6 +15,7 @@ import { import { buildKittyPlaceholder, createRendererChildEnv, + encodeKittyImage, encodeKittyVirtualImage, findExecutable, readPngSize, @@ -50,6 +51,11 @@ export type TerminalImageRenderResult = sequence: string; placeholder: KittyImagePlaceholder; } + | { + kind: 'kitty-direct'; + sequence: string; + rows: number; + } | { kind: 'ansi'; lines: string[] } | { kind: 'unavailable'; reason: string }; @@ -82,6 +88,20 @@ export function supportsKittyImageProtocol( ); } +export function supportsKittyDirectImageProtocol( + env: NodeJS.ProcessEnv = process.env, + stdoutIsTTY = process.stdout.isTTY, +): boolean { + return Boolean( + process.platform !== 'win32' && + stdoutIsTTY && + !env['TMUX'] && + !env['SSH_TTY'] && + !env['SSH_CLIENT'] && + env['TERM_PROGRAM']?.toLowerCase() === 'warpterminal', + ); +} + export function getTerminalImageRenderSupport( env: NodeJS.ProcessEnv = process.env, stdoutIsTTY = process.stdout.isTTY, @@ -89,6 +109,9 @@ export function getTerminalImageRenderSupport( if (supportsKittyImageProtocol(env, stdoutIsTTY)) { return { available: true }; } + if (supportsKittyDirectImageProtocol(env, stdoutIsTTY)) { + return { available: true }; + } // Detect chafa with a PATH lookup instead of rendering the user's image: // this runs during display_image execution and must never spawn a @@ -150,13 +173,17 @@ export function renderTerminalImage({ contentWidth, availableTerminalHeight, ); - const useKitty = supportsKittyImageProtocol(env, stdoutIsTTY); - const chafaPath = useKitty ? null : findExecutable('chafa', env); + const renderer = supportsKittyDirectImageProtocol(env, stdoutIsTTY) + ? 'kitty-direct' + : supportsKittyImageProtocol(env, stdoutIsTTY) + ? 'kitty-placeholder' + : 'chafa'; + const chafaPath = renderer === 'chafa' ? findExecutable('chafa', env) : null; const cacheKey = createRenderCacheKey( display.filePath, stat, shape, - useKitty, + renderer, chafaPath, ); const cached = getCachedRenderResult(cacheKey); @@ -180,9 +207,12 @@ export function renderTerminalImage({ }; } - const result: TerminalImageRenderResult = useKitty - ? renderKitty(png, shape) - : renderWithChafa(display.filePath, shape, env, chafaPath); + const result: TerminalImageRenderResult = + renderer === 'kitty-direct' + ? renderKittyDirect(png, shape) + : renderer === 'kitty-placeholder' + ? renderKitty(png, shape) + : renderWithChafa(display.filePath, shape, env, chafaPath); if (result.kind !== 'unavailable') { rememberRenderResult(cacheKey, result); } @@ -215,7 +245,7 @@ function createRenderCacheKey( filePath: string, stat: fs.Stats, shape: { widthCells: number; rows: number }, - useKitty: boolean, + renderer: 'kitty-direct' | 'kitty-placeholder' | 'chafa', chafaPath: string | null, ): string { return [ @@ -224,7 +254,7 @@ function createRenderCacheKey( stat.size, shape.widthCells, shape.rows, - useKitty ? 'kitty' : (chafaPath ?? 'none'), + renderer === 'chafa' ? (chafaPath ?? 'none') : renderer, ].join('\0'); } @@ -279,6 +309,8 @@ function estimateRenderResultBytes(result: TerminalImageRenderResult): number { 0, ) ); + case 'kitty-direct': + return Buffer.byteLength(result.sequence, 'utf8'); case 'ansi': return result.lines.reduce( (total, line) => total + Buffer.byteLength(line, 'utf8'), @@ -359,6 +391,17 @@ function renderKitty( }; } +function renderKittyDirect( + png: Buffer, + shape: { widthCells: number; rows: number }, +): Extract { + return { + kind: 'kitty-direct', + sequence: encodeKittyImage(png, shape.widthCells, shape.rows), + rows: shape.rows, + }; +} + function renderWithChafa( filePath: string, shape: { widthCells: number; rows: number }, diff --git a/patches/ink+7.0.3.patch b/patches/ink+7.0.3.patch index fa594c2a3c4..8ed4c72fefa 100644 --- a/patches/ink+7.0.3.patch +++ b/patches/ink+7.0.3.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/ink/build/components/Text.d.ts b/node_modules/ink/build/components/Text.d.ts -index 1d7bf6b..29401a6 100644 +index 1d7bf6b..3f9a27d 100644 --- a/node_modules/ink/build/components/Text.d.ts +++ b/node_modules/ink/build/components/Text.d.ts -@@ -47,9 +47,13 @@ export type Props = { +@@ -47,9 +47,14 @@ export type Props = { This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `hard` is passed, Ink will fill each line to the full column width, breaking words as necessary. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off. */ readonly wrap?: Styles['textWrap']; @@ -10,15 +10,16 @@ index 1d7bf6b..29401a6 100644 + readonly selectionFlow?: string; + readonly selectionBreakAfter?: 'soft' | 'hard'; + readonly selectionJoiner?: string; ++ readonly terminalControl?: boolean; readonly children?: ReactNode; }; /** This component can display text and change its style to make it bold, underlined, italic, or strikethrough. */ -export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; -+export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; ++export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, terminalControl, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; diff --git a/node_modules/ink/build/components/Text.js b/node_modules/ink/build/components/Text.js -index 2e4b9ff..77ed0f6 100644 +index 2e4b9ff..cd33f86 100644 --- a/node_modules/ink/build/components/Text.js +++ b/node_modules/ink/build/components/Text.js @@ -6,7 +6,7 @@ import { backgroundContext } from './BackgroundContext.js'; @@ -26,7 +27,7 @@ index 2e4b9ff..77ed0f6 100644 This component can display text and change its style to make it bold, underlined, italic, or strikethrough. */ -export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) { -+export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', selectable = true, selectionFlow, selectionBreakAfter, selectionJoiner = '', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) { ++export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', selectable = true, selectionFlow, selectionBreakAfter, selectionJoiner = '', terminalControl = false, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) { const { isScreenReaderEnabled } = useContext(accessibilityContext); const inheritedBackgroundColor = useContext(backgroundContext); const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children; @@ -35,15 +36,14 @@ index 2e4b9ff..77ed0f6 100644 return null; } - return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform }, childrenOrAriaLabel)); -+ return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner }, childrenOrAriaLabel)); ++ return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner, terminalControl: terminalControl }, childrenOrAriaLabel)); } //# sourceMappingURL=Text.js.map -\ No newline at end of file diff --git a/node_modules/ink/build/cursor-helpers.d.ts b/node_modules/ink/build/cursor-helpers.d.ts index c6e9466..6dd4c0f 100644 --- a/node_modules/ink/build/cursor-helpers.d.ts +++ b/node_modules/ink/build/cursor-helpers.d.ts -@@ -11,9 +11,11 @@ +@@ -11,9 +11,11 @@ Compare two cursor positions. Returns true if they differ. export declare const cursorPositionChanged: (a: CursorPosition | undefined, b: CursorPosition | undefined) => boolean; /** Build escape sequence to move cursor from bottom of output to the target position and show it. @@ -57,7 +57,7 @@ index c6e9466..6dd4c0f 100644 /** Build escape sequence to move cursor from previousCursorPosition back to the bottom of output. This must be done before eraseLines or any operation that assumes cursor is at the bottom. -@@ -25,6 +27,7 @@ +@@ -25,6 +27,7 @@ export type CursorOnlyInput = { previousCursorPosition: CursorPosition | undefined; visibleLineCount: number; cursorPosition: CursorPosition | undefined; @@ -110,6 +110,20 @@ index be49354..14215f0 100644 return hidePrefix + returnToBottom + cursorSuffix; }; /** +diff --git a/node_modules/ink/build/dom.js b/node_modules/ink/build/dom.js +index dce2276..80e2282 100644 +--- a/node_modules/ink/build/dom.js ++++ b/node_modules/ink/build/dom.js +@@ -89,6 +89,9 @@ export const createTextNode = (text) => { + return node; + }; + const measureTextNode = function (node, width) { ++ if (node.nodeName === 'ink-text' && node.attributes.terminalControl === true) { ++ return { width: 0, height: 0 }; ++ } + const text = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node); + const dimensions = measureText(text); + // Text fits into container, no need to wrap diff --git a/node_modules/ink/build/frame-controller.d.ts b/node_modules/ink/build/frame-controller.d.ts new file mode 100644 index 0000000..c294036 @@ -487,7 +501,7 @@ index 0f4523f..a3ef634 100644 } export {}; diff --git a/node_modules/ink/build/output.js b/node_modules/ink/build/output.js -index c763b77..bfd3d08 100644 +index c763b77..26fc082 100644 --- a/node_modules/ink/build/output.js +++ b/node_modules/ink/build/output.js @@ -1,6 +1,26 @@ @@ -526,16 +540,27 @@ index c763b77..bfd3d08 100644 if (!text) { return; } -@@ -55,6 +75,8 @@ export default class Output { +@@ -55,6 +75,19 @@ export default class Output { y, text, transformers, + selectable, + semantic, ++ }); ++ } ++ control(x, y, value) { ++ if (!value) { ++ return; ++ } ++ this.operations.push({ ++ type: 'control', ++ x, ++ y, ++ value, }); } clip(clip) { -@@ -68,7 +90,7 @@ export default class Output { +@@ -68,7 +101,7 @@ export default class Output { type: 'unclip', }); } @@ -544,7 +569,7 @@ index c763b77..bfd3d08 100644 // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved const output = []; for (let y = 0; y < this.height; y++) { -@@ -79,10 +101,13 @@ export default class Output { +@@ -79,10 +112,13 @@ export default class Output { value: ' ', fullWidth: false, styles: [], @@ -558,9 +583,22 @@ index c763b77..bfd3d08 100644 const clips = []; for (const operation of this.operations) { if (operation.type === 'clip') { -@@ -92,9 +117,10 @@ export default class Output { +@@ -91,10 +127,23 @@ export default class Output { + if (operation.type === 'unclip') { clips.pop(); } ++ if (operation.type === 'control') { ++ const clip = clips.at(-1); ++ const insideClip = (!clip || ++ ((clip.x1 === undefined || operation.x >= clip.x1) && ++ (clip.x2 === undefined || operation.x < clip.x2) && ++ (clip.y1 === undefined || operation.y >= clip.y1) && ++ (clip.y2 === undefined || operation.y < clip.y2))); ++ const cell = insideClip ? output[operation.y]?.[operation.x] : undefined; ++ if (cell) { ++ cell.controls = [...(cell.controls ?? []), operation.value]; ++ } ++ } if (operation.type === 'write') { - const { text, transformers } = operation; + const { text, transformers, selectable, semantic } = operation; @@ -570,7 +608,7 @@ index c763b77..bfd3d08 100644 const clip = clips.at(-1); if (clip) { const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'; -@@ -129,6 +155,7 @@ export default class Output { +@@ -129,6 +178,7 @@ export default class Output { const height = lines.length; const to = y + height > clip.y2 ? clip.y2 - y : height; lines = lines.slice(from, to); @@ -578,7 +616,7 @@ index c763b77..bfd3d08 100644 if (y < clip.y1) { y = clip.y1; } -@@ -144,8 +171,34 @@ export default class Output { +@@ -144,8 +194,34 @@ export default class Output { for (const transformer of transformers) { line = transformer(line, index); } @@ -613,7 +651,7 @@ index c763b77..bfd3d08 100644 // Nothing to write (e.g. line was clipped away). if (characters.length === 0) { offsetY++; -@@ -156,6 +209,8 @@ export default class Output { +@@ -156,6 +232,8 @@ export default class Output { value: ' ', fullWidth: false, styles: [], @@ -622,20 +660,22 @@ index c763b77..bfd3d08 100644 }; // Wide characters (e.g. CJK) occupy two cells: a leading // cell with the character and a trailing placeholder with -@@ -169,7 +224,11 @@ export default class Output { +@@ -169,7 +247,13 @@ export default class Output { currentLine[offsetX - 1] = spaceCell; } for (const character of characters) { - currentLine[offsetX] = character; ++ const controls = currentLine[offsetX]?.controls; + currentLine[offsetX] = { + ...character, + selectable: rowSelectable, + flowId: semantic?.flowId ?? null, ++ ...(controls?.length ? { controls } : {}), + }; // Determine printed width using string-width to align with measurement const characterWidth = Math.max(1, this.caches.getStringWidth(character.value)); // For multi-column characters, clear following cells to avoid stray spaces/artifacts -@@ -180,6 +239,8 @@ export default class Output { +@@ -180,6 +264,8 @@ export default class Output { value: '', fullWidth: false, styles: character.styles, @@ -644,7 +684,7 @@ index c763b77..bfd3d08 100644 }; } } -@@ -192,6 +253,30 @@ export default class Output { +@@ -192,16 +278,45 @@ export default class Output { } } } @@ -675,7 +715,14 @@ index c763b77..bfd3d08 100644 const generatedOutput = output .map(line => { // See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742 -@@ -202,6 +287,8 @@ export default class Output { + const lineWithoutEmptyItems = line.filter(item => item !== undefined); +- return styledCharsToString(lineWithoutEmptyItems).trimEnd(); ++ const lineWithControls = lineWithoutEmptyItems.map(item => item.controls?.length ++ ? { ...item, value: `${item.controls.join('')}${item.value}` } ++ : item); ++ return styledCharsToString(lineWithControls).trimEnd(); + }) + .join('\n'); return { output: generatedOutput, height: output.length, @@ -777,7 +824,7 @@ index 127c620..6d5a005 100644 }) => void; export default renderNodeToOutput; diff --git a/node_modules/ink/build/render-node-to-output.js b/node_modules/ink/build/render-node-to-output.js -index f00a278..e6bc4ca 100644 +index f00a278..3df8358 100644 --- a/node_modules/ink/build/render-node-to-output.js +++ b/node_modules/ink/build/render-node-to-output.js @@ -1,7 +1,6 @@ @@ -820,13 +867,20 @@ index f00a278..e6bc4ca 100644 if (skipStaticElements && node.internal_static) { return; } -@@ -88,16 +88,54 @@ const renderNodeToOutput = (node, output, options) => { +@@ -88,16 +88,61 @@ const renderNodeToOutput = (node, output, options) => { newTransformers = [node.internal_transform, ...transformers]; } if (node.nodeName === 'ink-text') { - let text = squashTextNodes(node); - if (text.length > 0) { - const currentWidth = widestLine(text); ++ if (node.attributes.terminalControl === true) { ++ const control = squashTextNodes(node, false); ++ if (control.length > 0) { ++ output.control(x, y, control); ++ } ++ return; ++ } + const sourceText = squashTextNodes(node); + if (sourceText.length > 0) { const maxWidth = getMaxWidth(yogaNode); @@ -840,7 +894,9 @@ index f00a278..e6bc4ca 100644 + if (flowId === undefined) { + flowId = nextFlowId.value++; + flowIds.set(flowKey, flowId); -+ } + } +- text = applyPaddingToText(node, text); +- output.write(x, y, text, { transformers: newTransformers }); + const boundaries = [...wrapped.boundaries]; + const breakAfter = node.attributes.selectionBreakAfter; + boundaries.push(breakAfter === 'soft' || breakAfter === 'hard' @@ -868,9 +924,7 @@ index f00a278..e6bc4ca 100644 + boundaries: wrapped.boundaries.map(() => null), + }, + }); - } -- text = applyPaddingToText(node, text); -- output.write(x, y, text, { transformers: newTransformers }); ++ } + output.write(x + textOffset.x, y + textOffset.y, wrapped.text, { + transformers: newTransformers, + semantic: { @@ -883,7 +937,7 @@ index f00a278..e6bc4ca 100644 } return; } -@@ -135,6 +173,8 @@ const renderNodeToOutput = (node, output, options) => { +@@ -135,6 +180,8 @@ const renderNodeToOutput = (node, output, options) => { offsetY: y, transformers: newTransformers, skipStaticElements, @@ -955,6 +1009,37 @@ index bf23246..7fa831f 100644 }; } return { +diff --git a/node_modules/ink/build/squash-text-nodes.js b/node_modules/ink/build/squash-text-nodes.js +index 924e04a..2fc6d39 100644 +--- a/node_modules/ink/build/squash-text-nodes.js ++++ b/node_modules/ink/build/squash-text-nodes.js +@@ -5,7 +5,7 @@ import sanitizeAnsi from './sanitize-ansi.js'; + // + // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link), + // which need to wrap all children at once, instead of wrapping 3 text nodes separately. +-const squashTextNodes = (node) => { ++const squashTextNodes = (node, sanitize = true) => { + let text = ''; + for (let index = 0; index < node.childNodes.length; index++) { + const childNode = node.childNodes[index]; +@@ -19,7 +19,7 @@ const squashTextNodes = (node) => { + else { + if (childNode.nodeName === 'ink-text' || + childNode.nodeName === 'ink-virtual-text') { +- nodeText = squashTextNodes(childNode); ++ nodeText = squashTextNodes(childNode, sanitize); + } + // Since these text nodes are being concatenated, `Output` instance won't be able to + // apply children transform, so we have to do it manually here for each text node +@@ -30,7 +30,7 @@ const squashTextNodes = (node) => { + } + text += nodeText; + } +- return sanitizeAnsi(text); ++ return sanitize ? sanitizeAnsi(text) : text; + }; + export default squashTextNodes; + //# sourceMappingURL=squash-text-nodes.js.map diff --git a/node_modules/ink/build/wrap-text.d.ts b/node_modules/ink/build/wrap-text.d.ts index 935cfb8..3dddced 100644 --- a/node_modules/ink/build/wrap-text.d.ts From e4921a090536d4433f56c635e89c542c18a49d84 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:17:49 +0800 Subject: [PATCH 13/15] fix(cli): disable native images in Warp --- docs/design/tui-display-image-tool.md | 35 +++-- .../src/ui/components/TerminalImage.test.tsx | 28 +--- .../cli/src/ui/components/TerminalImage.tsx | 17 --- .../src/ui/utils/mermaidImageRenderer.test.ts | 2 +- .../cli/src/ui/utils/mermaidImageRenderer.ts | 5 +- .../ui/utils/terminal-image-renderer.test.ts | 120 +++++----------- .../src/ui/utils/terminal-image-renderer.ts | 59 ++------ patches/ink+7.0.3.patch | 136 ++++-------------- 8 files changed, 86 insertions(+), 316 deletions(-) diff --git a/docs/design/tui-display-image-tool.md b/docs/design/tui-display-image-tool.md index e56438d7f1a..4b976b1e5e5 100644 --- a/docs/design/tui-display-image-tool.md +++ b/docs/design/tui-display-image-tool.md @@ -35,16 +35,12 @@ component. The component revalidates the path against the current workspace before reading it. Rendering is prepared during the component's first render because completed tool rows enter Ink's append-only `Static` region immediately; an asynchronous state update would be dropped there. -Kitty and Ghostty use the existing virtual-image and Unicode-placeholder -approach already used for Mermaid diagrams. Warp uses the Kitty protocol's -direct `a=T` placement because it supports the base protocol but not the -placeholder extension. The direct command sets `C=1`, so the terminal does not -move Ink's cursor, and an opt-in Ink text node emits the trusted command at the -image's exact position while ordinary text continues to strip terminal control -sequences. Other terminals try `chafa` with symbol output. `chafa` receives -only the same allowlisted environment used by the Mermaid renderer, so API -credentials are not forwarded. If neither path is available, the TUI shows a -bounded text fallback naming the image. +Kitty-compatible terminals use the existing virtual-image and +Unicode-placeholder approach already used for Mermaid diagrams. Other +terminals try `chafa` with symbol output. `chafa` receives only the same +allowlisted environment used by the Mermaid renderer, so API credentials are +not forwarded. If neither path is available, the TUI shows a bounded text +fallback naming the image. Previews are capped at 72 columns and 24 rows, then reduced further to fit the available terminal space. An 8-by-16-pixel terminal cell estimate provides a @@ -62,11 +58,14 @@ Bare mode keeps its existing minimal tool set. ## Compatibility -The first version supports PNG only. Kitty and Ghostty use virtual native image -placement; Warp uses direct native Kitty placement on macOS and Linux without -Unicode placeholders. Direct iTerm2 placement remains excluded. Warp on -Windows, Warp sessions inside tmux or SSH, iTerm2, and terminals without a -native path render through `chafa` when it is installed. +The first version supports PNG only. Kitty and Ghostty use native image +placement. Warp is intentionally excluded because its Kitty support does not +include the Unicode-placeholder extension used by this renderer, while direct +placement drifts away from Ink content during scrollback and terminal reflow. +Direct iTerm2 placement is also excluded because its inline image protocol is +cursor-positioned and the existing asynchronous Ink path already treats it as +unsafe. Warp, iTerm2, tmux, SSH, and terminals without a native path can still +render through `chafa` when it is installed. ## Security and persistence @@ -76,7 +75,5 @@ The renderer treats restored `resultDisplay` data as untrusted and repeats this check. Persisted sessions contain only the path and MIME type; if the file no longer exists, restore degrades to a text notice. -Raw terminal output is generated only by the trusted renderer. Ink's direct -terminal-control path is opt-in on the image component, so model-controlled -strings cannot use it. Model-controlled strings and file contents are never -written directly to the terminal. +Raw terminal output is generated only by the trusted renderer. Model-controlled +strings and file contents are never written directly to the terminal. diff --git a/packages/cli/src/ui/components/TerminalImage.test.tsx b/packages/cli/src/ui/components/TerminalImage.test.tsx index 7cbeab41f33..bb95c464de8 100644 --- a/packages/cli/src/ui/components/TerminalImage.test.tsx +++ b/packages/cli/src/ui/components/TerminalImage.test.tsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'ink-testing-library'; -import { Static, Text } from 'ink'; +import { Static } from 'ink'; import type { Config } from '@qwen-code/qwen-code-core'; import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; import { @@ -89,32 +89,6 @@ describe('TerminalImage', () => { expect(lastFrame()).toContain('▄▄'); }); - it('emits direct Kitty placement inline and reserves its rows', () => { - const sequence = '\x1b_Ga=T,f=100,q=2,C=1,c=4,r=2;payload\x1b\\'; - const { stdout } = renderImage({ - kind: 'kitty-direct', - sequence, - rows: 2, - }); - - const directFrame = stdout.frames.find((frame) => frame.includes(sequence)); - expect(Buffer.from(directFrame ?? '').toString('hex')).toBe( - Buffer.from(`${sequence}\n\n`).toString('hex'), - ); - }); - - it('keeps terminal controls opt-in for ordinary text', () => { - const sequence = '\x1b_Ga=T,f=100;payload\x1b\\'; - const { stdout } = render( - - {(item) => {item}safe} - , - ); - - expect(stdout.frames.join('')).not.toContain(sequence); - expect(stdout.frames.join('')).toContain('safe'); - }); - it('shows a readable fallback when no renderer is available', () => { const { lastFrame } = renderImage({ kind: 'unavailable', diff --git a/packages/cli/src/ui/components/TerminalImage.tsx b/packages/cli/src/ui/components/TerminalImage.tsx index fda15b32458..d530a86b3e4 100644 --- a/packages/cli/src/ui/components/TerminalImage.tsx +++ b/packages/cli/src/ui/components/TerminalImage.tsx @@ -94,23 +94,6 @@ export const TerminalImage: React.FC = ({ ); } - if (result.kind === 'kitty-direct') { - return ( - - - - {result.sequence} - - - - {Array.from({ length: result.rows - 1 }, (_, index) => ( - - - - ))} - - ); - } return ( { '\u001b]1337;File=inline=1;width=40;height=10;', ); expect(encodeKittyImage(PNG_1X1, 40, 10)).toContain( - '\u001b_Ga=T,f=100,q=2,C=1,c=40,r=10,', + '\u001b_Ga=T,f=100,c=40,r=10,', ); expect(encodeKittyVirtualImage(PNG_1X1, 42, 40, 10)).toContain( '\u001b_Ga=T,f=100,i=42,q=2,U=1,c=40,r=10,', diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 64585411a1c..1d8b775e1f9 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -285,10 +285,7 @@ export function encodeKittyImage( widthCells: number, rows: number, ): string { - return encodeKittyImageCommand( - png, - `a=T,f=100,q=2,C=1,c=${widthCells},r=${rows}`, - ); + return encodeKittyImageCommand(png, `a=T,f=100,c=${widthCells},r=${rows}`); } export function encodeKittyVirtualImage( diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 6ce6ce9cb88..19ddcf7dd2f 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -12,7 +12,6 @@ import { containsCmdShellMetacharacters, getTerminalImageRenderSupport, renderTerminalImage, - supportsKittyDirectImageProtocol, supportsKittyImageProtocol, } from './terminal-image-renderer.js'; @@ -164,85 +163,34 @@ describe('terminalImageRenderer', () => { ).toBe(false); }); - it.runIf(process.platform !== 'win32')( - 'uses direct Kitty placement without Unicode placeholders in Warp', - () => { - expect( - supportsKittyImageProtocol( - { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, - true, - ), - ).toBe(false); - expect( - supportsKittyImageProtocol( - { TERM: 'xterm-kitty', TERM_PROGRAM: 'WarpTerminal' }, - true, - ), - ).toBe(false); - expect( - supportsKittyDirectImageProtocol( - { - TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', - }, - true, - ), - ).toBe(true); - expect( - supportsKittyDirectImageProtocol( - { TERM_PROGRAM: 'WarpTerminal', TMUX: '/tmp/tmux' }, - true, - ), - ).toBe(false); - expect( - supportsKittyDirectImageProtocol( - { TERM_PROGRAM: 'WarpTerminal', SSH_TTY: '/dev/pts/1' }, - true, - ), - ).toBe(false); - expect( - supportsKittyDirectImageProtocol( - { TERM_PROGRAM: 'WarpTerminal', SSH_CLIENT: '10.0.0.1 51234 22' }, - true, - ), - ).toBe(false); - expect( - supportsKittyDirectImageProtocol( - { TERM_PROGRAM: 'WarpTerminal' }, - false, - ), - ).toBe(false); - expect( - getTerminalImageRenderSupport( - { - PATH: tempDir, - TERM: 'xterm-256color', - TERM_PROGRAM: 'WarpTerminal', - }, - true, - ), - ).toEqual({ available: true }); - - const result = renderTerminalImage({ - display: { - type: 'terminal_image', - filePath: imagePath, - mimeType: 'image/png', + it('does not use Kitty Unicode placeholders in Warp', () => { + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, + true, + ), + ).toBe(false); + expect( + supportsKittyImageProtocol( + { TERM: 'xterm-kitty', TERM_PROGRAM: 'WarpTerminal' }, + true, + ), + ).toBe(false); + expect( + getTerminalImageRenderSupport( + { + PATH: tempDir, + TERM: 'xterm-256color', + TERM_PROGRAM: 'WarpTerminal', }, - contentWidth: 24, - availableTerminalHeight: 12, - env: { TERM: 'xterm-256color', TERM_PROGRAM: 'WarpTerminal' }, - stdoutIsTTY: true, - }); - - expect(result.kind).toBe('kitty-direct'); - if (result.kind !== 'kitty-direct') return; - expect(result.sequence).toContain('\u001b_Ga=T,f=100,q=2,C=1'); - expect(result.sequence).not.toContain('U=1'); - expect(result.sequence).toContain('c=1,r=1'); - expect(result.rows).toBe(1); - }, - ); + true, + ), + ).toEqual({ + available: false, + reason: + 'No compatible native image protocol was detected, and chafa is not installed.', + }); + }); it.runIf(process.platform !== 'win32')( 'falls back to chafa symbol output', @@ -267,7 +215,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', TEST_RENDERER_SECRET: 'must-not-reach-chafa', }, stdoutIsTTY: true, @@ -282,7 +230,7 @@ describe('terminalImageRenderer', () => { { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', }, true, ), @@ -307,7 +255,7 @@ describe('terminalImageRenderer', () => { { PATH: localBin, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', }, true, ), @@ -327,7 +275,7 @@ describe('terminalImageRenderer', () => { env: { PATH: localBin, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', }, stdoutIsTTY: true, }); @@ -361,7 +309,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', }, stdoutIsTTY: true, }); @@ -395,7 +343,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', }, stdoutIsTTY: true, }); @@ -431,7 +379,7 @@ describe('terminalImageRenderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, TERM: 'xterm-256color', - TERM_PROGRAM: 'Apple_Terminal', + TERM_PROGRAM: 'WarpTerminal', }, stdoutIsTTY: true, }; diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index 061f46a946c..7538051a700 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -15,7 +15,6 @@ import { import { buildKittyPlaceholder, createRendererChildEnv, - encodeKittyImage, encodeKittyVirtualImage, findExecutable, readPngSize, @@ -51,11 +50,6 @@ export type TerminalImageRenderResult = sequence: string; placeholder: KittyImagePlaceholder; } - | { - kind: 'kitty-direct'; - sequence: string; - rows: number; - } | { kind: 'ansi'; lines: string[] } | { kind: 'unavailable'; reason: string }; @@ -88,20 +82,6 @@ export function supportsKittyImageProtocol( ); } -export function supportsKittyDirectImageProtocol( - env: NodeJS.ProcessEnv = process.env, - stdoutIsTTY = process.stdout.isTTY, -): boolean { - return Boolean( - process.platform !== 'win32' && - stdoutIsTTY && - !env['TMUX'] && - !env['SSH_TTY'] && - !env['SSH_CLIENT'] && - env['TERM_PROGRAM']?.toLowerCase() === 'warpterminal', - ); -} - export function getTerminalImageRenderSupport( env: NodeJS.ProcessEnv = process.env, stdoutIsTTY = process.stdout.isTTY, @@ -109,9 +89,6 @@ export function getTerminalImageRenderSupport( if (supportsKittyImageProtocol(env, stdoutIsTTY)) { return { available: true }; } - if (supportsKittyDirectImageProtocol(env, stdoutIsTTY)) { - return { available: true }; - } // Detect chafa with a PATH lookup instead of rendering the user's image: // this runs during display_image execution and must never spawn a @@ -173,17 +150,13 @@ export function renderTerminalImage({ contentWidth, availableTerminalHeight, ); - const renderer = supportsKittyDirectImageProtocol(env, stdoutIsTTY) - ? 'kitty-direct' - : supportsKittyImageProtocol(env, stdoutIsTTY) - ? 'kitty-placeholder' - : 'chafa'; - const chafaPath = renderer === 'chafa' ? findExecutable('chafa', env) : null; + const useKitty = supportsKittyImageProtocol(env, stdoutIsTTY); + const chafaPath = useKitty ? null : findExecutable('chafa', env); const cacheKey = createRenderCacheKey( display.filePath, stat, shape, - renderer, + useKitty, chafaPath, ); const cached = getCachedRenderResult(cacheKey); @@ -207,12 +180,9 @@ export function renderTerminalImage({ }; } - const result: TerminalImageRenderResult = - renderer === 'kitty-direct' - ? renderKittyDirect(png, shape) - : renderer === 'kitty-placeholder' - ? renderKitty(png, shape) - : renderWithChafa(display.filePath, shape, env, chafaPath); + const result: TerminalImageRenderResult = useKitty + ? renderKitty(png, shape) + : renderWithChafa(display.filePath, shape, env, chafaPath); if (result.kind !== 'unavailable') { rememberRenderResult(cacheKey, result); } @@ -245,7 +215,7 @@ function createRenderCacheKey( filePath: string, stat: fs.Stats, shape: { widthCells: number; rows: number }, - renderer: 'kitty-direct' | 'kitty-placeholder' | 'chafa', + useKitty: boolean, chafaPath: string | null, ): string { return [ @@ -254,7 +224,7 @@ function createRenderCacheKey( stat.size, shape.widthCells, shape.rows, - renderer === 'chafa' ? (chafaPath ?? 'none') : renderer, + useKitty ? 'kitty' : (chafaPath ?? 'none'), ].join('\0'); } @@ -309,8 +279,6 @@ function estimateRenderResultBytes(result: TerminalImageRenderResult): number { 0, ) ); - case 'kitty-direct': - return Buffer.byteLength(result.sequence, 'utf8'); case 'ansi': return result.lines.reduce( (total, line) => total + Buffer.byteLength(line, 'utf8'), @@ -391,17 +359,6 @@ function renderKitty( }; } -function renderKittyDirect( - png: Buffer, - shape: { widthCells: number; rows: number }, -): Extract { - return { - kind: 'kitty-direct', - sequence: encodeKittyImage(png, shape.widthCells, shape.rows), - rows: shape.rows, - }; -} - function renderWithChafa( filePath: string, shape: { widthCells: number; rows: number }, diff --git a/patches/ink+7.0.3.patch b/patches/ink+7.0.3.patch index 8ed4c72fefa..4f002764556 100644 --- a/patches/ink+7.0.3.patch +++ b/patches/ink+7.0.3.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/ink/build/components/Text.d.ts b/node_modules/ink/build/components/Text.d.ts -index 1d7bf6b..3f9a27d 100644 +index 1d7bf6b..29401a6 100644 --- a/node_modules/ink/build/components/Text.d.ts +++ b/node_modules/ink/build/components/Text.d.ts -@@ -47,9 +47,14 @@ export type Props = { +@@ -47,9 +47,13 @@ export type Props = { This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `hard` is passed, Ink will fill each line to the full column width, breaking words as necessary. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off. */ readonly wrap?: Styles['textWrap']; @@ -10,16 +10,15 @@ index 1d7bf6b..3f9a27d 100644 + readonly selectionFlow?: string; + readonly selectionBreakAfter?: 'soft' | 'hard'; + readonly selectionJoiner?: string; -+ readonly terminalControl?: boolean; readonly children?: ReactNode; }; /** This component can display text and change its style to make it bold, underlined, italic, or strikethrough. */ -export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; -+export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, terminalControl, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; ++export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null; diff --git a/node_modules/ink/build/components/Text.js b/node_modules/ink/build/components/Text.js -index 2e4b9ff..cd33f86 100644 +index 2e4b9ff..1ec2ae6 100644 --- a/node_modules/ink/build/components/Text.js +++ b/node_modules/ink/build/components/Text.js @@ -6,7 +6,7 @@ import { backgroundContext } from './BackgroundContext.js'; @@ -27,7 +26,7 @@ index 2e4b9ff..cd33f86 100644 This component can display text and change its style to make it bold, underlined, italic, or strikethrough. */ -export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) { -+export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', selectable = true, selectionFlow, selectionBreakAfter, selectionJoiner = '', terminalControl = false, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) { ++export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', selectable = true, selectionFlow, selectionBreakAfter, selectionJoiner = '', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) { const { isScreenReaderEnabled } = useContext(accessibilityContext); const inheritedBackgroundColor = useContext(backgroundContext); const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children; @@ -36,7 +35,7 @@ index 2e4b9ff..cd33f86 100644 return null; } - return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform }, childrenOrAriaLabel)); -+ return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner, terminalControl: terminalControl }, childrenOrAriaLabel)); ++ return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner }, childrenOrAriaLabel)); } //# sourceMappingURL=Text.js.map diff --git a/node_modules/ink/build/cursor-helpers.d.ts b/node_modules/ink/build/cursor-helpers.d.ts @@ -110,20 +109,6 @@ index be49354..14215f0 100644 return hidePrefix + returnToBottom + cursorSuffix; }; /** -diff --git a/node_modules/ink/build/dom.js b/node_modules/ink/build/dom.js -index dce2276..80e2282 100644 ---- a/node_modules/ink/build/dom.js -+++ b/node_modules/ink/build/dom.js -@@ -89,6 +89,9 @@ export const createTextNode = (text) => { - return node; - }; - const measureTextNode = function (node, width) { -+ if (node.nodeName === 'ink-text' && node.attributes.terminalControl === true) { -+ return { width: 0, height: 0 }; -+ } - const text = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node); - const dimensions = measureText(text); - // Text fits into container, no need to wrap diff --git a/node_modules/ink/build/frame-controller.d.ts b/node_modules/ink/build/frame-controller.d.ts new file mode 100644 index 0000000..c294036 @@ -501,7 +486,7 @@ index 0f4523f..a3ef634 100644 } export {}; diff --git a/node_modules/ink/build/output.js b/node_modules/ink/build/output.js -index c763b77..26fc082 100644 +index c763b77..ac81f2e 100644 --- a/node_modules/ink/build/output.js +++ b/node_modules/ink/build/output.js @@ -1,6 +1,26 @@ @@ -540,27 +525,16 @@ index c763b77..26fc082 100644 if (!text) { return; } -@@ -55,6 +75,19 @@ export default class Output { +@@ -55,6 +75,8 @@ export default class Output { y, text, transformers, + selectable, + semantic, -+ }); -+ } -+ control(x, y, value) { -+ if (!value) { -+ return; -+ } -+ this.operations.push({ -+ type: 'control', -+ x, -+ y, -+ value, }); } clip(clip) { -@@ -68,7 +101,7 @@ export default class Output { +@@ -68,7 +90,7 @@ export default class Output { type: 'unclip', }); } @@ -569,7 +543,7 @@ index c763b77..26fc082 100644 // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved const output = []; for (let y = 0; y < this.height; y++) { -@@ -79,10 +112,13 @@ export default class Output { +@@ -79,10 +101,13 @@ export default class Output { value: ' ', fullWidth: false, styles: [], @@ -583,22 +557,9 @@ index c763b77..26fc082 100644 const clips = []; for (const operation of this.operations) { if (operation.type === 'clip') { -@@ -91,10 +127,23 @@ export default class Output { - if (operation.type === 'unclip') { +@@ -92,9 +117,10 @@ export default class Output { clips.pop(); } -+ if (operation.type === 'control') { -+ const clip = clips.at(-1); -+ const insideClip = (!clip || -+ ((clip.x1 === undefined || operation.x >= clip.x1) && -+ (clip.x2 === undefined || operation.x < clip.x2) && -+ (clip.y1 === undefined || operation.y >= clip.y1) && -+ (clip.y2 === undefined || operation.y < clip.y2))); -+ const cell = insideClip ? output[operation.y]?.[operation.x] : undefined; -+ if (cell) { -+ cell.controls = [...(cell.controls ?? []), operation.value]; -+ } -+ } if (operation.type === 'write') { - const { text, transformers } = operation; + const { text, transformers, selectable, semantic } = operation; @@ -608,7 +569,7 @@ index c763b77..26fc082 100644 const clip = clips.at(-1); if (clip) { const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'; -@@ -129,6 +178,7 @@ export default class Output { +@@ -129,6 +155,7 @@ export default class Output { const height = lines.length; const to = y + height > clip.y2 ? clip.y2 - y : height; lines = lines.slice(from, to); @@ -616,7 +577,7 @@ index c763b77..26fc082 100644 if (y < clip.y1) { y = clip.y1; } -@@ -144,8 +194,34 @@ export default class Output { +@@ -144,8 +171,34 @@ export default class Output { for (const transformer of transformers) { line = transformer(line, index); } @@ -651,7 +612,7 @@ index c763b77..26fc082 100644 // Nothing to write (e.g. line was clipped away). if (characters.length === 0) { offsetY++; -@@ -156,6 +232,8 @@ export default class Output { +@@ -156,6 +209,8 @@ export default class Output { value: ' ', fullWidth: false, styles: [], @@ -660,22 +621,20 @@ index c763b77..26fc082 100644 }; // Wide characters (e.g. CJK) occupy two cells: a leading // cell with the character and a trailing placeholder with -@@ -169,7 +247,13 @@ export default class Output { +@@ -169,7 +224,11 @@ export default class Output { currentLine[offsetX - 1] = spaceCell; } for (const character of characters) { - currentLine[offsetX] = character; -+ const controls = currentLine[offsetX]?.controls; + currentLine[offsetX] = { + ...character, + selectable: rowSelectable, + flowId: semantic?.flowId ?? null, -+ ...(controls?.length ? { controls } : {}), + }; // Determine printed width using string-width to align with measurement const characterWidth = Math.max(1, this.caches.getStringWidth(character.value)); // For multi-column characters, clear following cells to avoid stray spaces/artifacts -@@ -180,6 +264,8 @@ export default class Output { +@@ -180,6 +239,8 @@ export default class Output { value: '', fullWidth: false, styles: character.styles, @@ -684,7 +643,7 @@ index c763b77..26fc082 100644 }; } } -@@ -192,16 +278,45 @@ export default class Output { +@@ -192,6 +253,30 @@ export default class Output { } } } @@ -715,14 +674,7 @@ index c763b77..26fc082 100644 const generatedOutput = output .map(line => { // See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742 - const lineWithoutEmptyItems = line.filter(item => item !== undefined); -- return styledCharsToString(lineWithoutEmptyItems).trimEnd(); -+ const lineWithControls = lineWithoutEmptyItems.map(item => item.controls?.length -+ ? { ...item, value: `${item.controls.join('')}${item.value}` } -+ : item); -+ return styledCharsToString(lineWithControls).trimEnd(); - }) - .join('\n'); +@@ -202,6 +287,8 @@ export default class Output { return { output: generatedOutput, height: output.length, @@ -824,7 +776,7 @@ index 127c620..6d5a005 100644 }) => void; export default renderNodeToOutput; diff --git a/node_modules/ink/build/render-node-to-output.js b/node_modules/ink/build/render-node-to-output.js -index f00a278..3df8358 100644 +index f00a278..07a98d0 100644 --- a/node_modules/ink/build/render-node-to-output.js +++ b/node_modules/ink/build/render-node-to-output.js @@ -1,7 +1,6 @@ @@ -867,20 +819,13 @@ index f00a278..3df8358 100644 if (skipStaticElements && node.internal_static) { return; } -@@ -88,16 +88,61 @@ const renderNodeToOutput = (node, output, options) => { +@@ -88,16 +88,54 @@ const renderNodeToOutput = (node, output, options) => { newTransformers = [node.internal_transform, ...transformers]; } if (node.nodeName === 'ink-text') { - let text = squashTextNodes(node); - if (text.length > 0) { - const currentWidth = widestLine(text); -+ if (node.attributes.terminalControl === true) { -+ const control = squashTextNodes(node, false); -+ if (control.length > 0) { -+ output.control(x, y, control); -+ } -+ return; -+ } + const sourceText = squashTextNodes(node); + if (sourceText.length > 0) { const maxWidth = getMaxWidth(yogaNode); @@ -894,9 +839,7 @@ index f00a278..3df8358 100644 + if (flowId === undefined) { + flowId = nextFlowId.value++; + flowIds.set(flowKey, flowId); - } -- text = applyPaddingToText(node, text); -- output.write(x, y, text, { transformers: newTransformers }); ++ } + const boundaries = [...wrapped.boundaries]; + const breakAfter = node.attributes.selectionBreakAfter; + boundaries.push(breakAfter === 'soft' || breakAfter === 'hard' @@ -924,7 +867,9 @@ index f00a278..3df8358 100644 + boundaries: wrapped.boundaries.map(() => null), + }, + }); -+ } + } +- text = applyPaddingToText(node, text); +- output.write(x, y, text, { transformers: newTransformers }); + output.write(x + textOffset.x, y + textOffset.y, wrapped.text, { + transformers: newTransformers, + semantic: { @@ -937,7 +882,7 @@ index f00a278..3df8358 100644 } return; } -@@ -135,6 +180,8 @@ const renderNodeToOutput = (node, output, options) => { +@@ -135,6 +173,8 @@ const renderNodeToOutput = (node, output, options) => { offsetY: y, transformers: newTransformers, skipStaticElements, @@ -1009,37 +954,6 @@ index bf23246..7fa831f 100644 }; } return { -diff --git a/node_modules/ink/build/squash-text-nodes.js b/node_modules/ink/build/squash-text-nodes.js -index 924e04a..2fc6d39 100644 ---- a/node_modules/ink/build/squash-text-nodes.js -+++ b/node_modules/ink/build/squash-text-nodes.js -@@ -5,7 +5,7 @@ import sanitizeAnsi from './sanitize-ansi.js'; - // - // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link), - // which need to wrap all children at once, instead of wrapping 3 text nodes separately. --const squashTextNodes = (node) => { -+const squashTextNodes = (node, sanitize = true) => { - let text = ''; - for (let index = 0; index < node.childNodes.length; index++) { - const childNode = node.childNodes[index]; -@@ -19,7 +19,7 @@ const squashTextNodes = (node) => { - else { - if (childNode.nodeName === 'ink-text' || - childNode.nodeName === 'ink-virtual-text') { -- nodeText = squashTextNodes(childNode); -+ nodeText = squashTextNodes(childNode, sanitize); - } - // Since these text nodes are being concatenated, `Output` instance won't be able to - // apply children transform, so we have to do it manually here for each text node -@@ -30,7 +30,7 @@ const squashTextNodes = (node) => { - } - text += nodeText; - } -- return sanitizeAnsi(text); -+ return sanitize ? sanitizeAnsi(text) : text; - }; - export default squashTextNodes; - //# sourceMappingURL=squash-text-nodes.js.map diff --git a/node_modules/ink/build/wrap-text.d.ts b/node_modules/ink/build/wrap-text.d.ts index 935cfb8..3dddced 100644 --- a/node_modules/ink/build/wrap-text.d.ts From 913923db072fecbbc7210ad4439d16135e2d217e Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:37:01 +0800 Subject: [PATCH 14/15] test(cli): cover chafa shell path guard --- .../ui/utils/terminal-image-renderer.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 19ddcf7dd2f..3c014537736 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -238,6 +238,49 @@ describe('terminalImageRenderer', () => { }, ); + it('rejects cmd.exe metacharacters before invoking a shell shim', async () => { + const dangerousImagePath = path.join(tempDir, 'chart & whoami.png'); + await fs.writeFile(dangerousImagePath, PNG_1X1); + const chafaPath = path.join(tempDir, 'chafa.CMD'); + await fs.writeFile(chafaPath, '@echo off\r\n'); + await fs.chmod(chafaPath, 0o755); + + const platformDescriptor = Object.getOwnPropertyDescriptor( + process, + 'platform', + ); + Object.defineProperty(process, 'platform', { + ...platformDescriptor, + value: 'win32', + }); + try { + const result = renderTerminalImage({ + display: { + type: 'terminal_image', + filePath: dangerousImagePath, + mimeType: 'image/png', + }, + contentWidth: 20, + env: { + PATH: tempDir, + PATHEXT: '.CMD', + TERM_PROGRAM: 'WarpTerminal', + }, + stdoutIsTTY: true, + }); + + expect(result).toEqual({ + kind: 'unavailable', + reason: + 'Image path contains characters that cannot be safely passed to the renderer on this platform.', + }); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + it.runIf(process.platform !== 'win32')( 'does not trust a project-local node_modules/.bin/chafa', async () => { From e1a29846096a39549f82470a272ae0621d467811 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 1 Aug 2026 10:41:11 +0000 Subject: [PATCH 15/15] fix(cli): skip re-transmitting Kitty image payload on remount (#8217) --- .../src/ui/components/TerminalImage.test.tsx | 72 +++++++++++++------ .../cli/src/ui/components/TerminalImage.tsx | 20 +++--- .../ui/utils/terminal-image-renderer.test.ts | 50 +++++++++++++ .../src/ui/utils/terminal-image-renderer.ts | 30 +++++++- 4 files changed, 140 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/ui/components/TerminalImage.test.tsx b/packages/cli/src/ui/components/TerminalImage.test.tsx index bb95c464de8..3d321b5280b 100644 --- a/packages/cli/src/ui/components/TerminalImage.test.tsx +++ b/packages/cli/src/ui/components/TerminalImage.test.tsx @@ -15,8 +15,14 @@ import { } from '../utils/terminal-image-renderer.js'; import { TerminalImage } from './TerminalImage.js'; +const { writtenKeys } = vi.hoisted(() => ({ writtenKeys: new Set() })); + vi.mock('../utils/terminal-image-renderer.js', () => ({ renderTerminalImage: vi.fn(), + wasKittyImageWritten: vi.fn((key: string) => writtenKeys.has(key)), + markKittyImageWritten: vi.fn((key: string) => { + writtenKeys.add(key); + }), })); const mockedRenderTerminalImage = vi.mocked(renderTerminalImage); @@ -35,6 +41,17 @@ const IMAGE = { mimeType: 'image/png' as const, }; +const KITTY_RESULT: TerminalImageRenderResult = { + kind: 'kitty', + key: 'kitty-payload', + sequence: '\x1b_Gpayload\x1b\\', + placeholder: { + color: '#00002a', + imageId: 42, + lines: ['placeholder'], + }, +}; + function renderImage( result: TerminalImageRenderResult, writeRaw: (...args: unknown[]) => void = vi.fn(), @@ -58,23 +75,13 @@ function renderImage( describe('TerminalImage', () => { beforeEach(() => { + writtenKeys.clear(); vi.clearAllMocks(); }); it('writes trusted Kitty data and renders its placeholder', async () => { const writeRaw = vi.fn(); - const { lastFrame } = renderImage( - { - kind: 'kitty', - sequence: '\x1b_Gpayload\x1b\\', - placeholder: { - color: '#00002a', - imageId: 42, - lines: ['placeholder'], - }, - }, - writeRaw, - ); + const { lastFrame } = renderImage(KITTY_RESULT, writeRaw); await vi.waitFor(() => { expect(writeRaw).toHaveBeenCalledWith('\x1b_Gpayload\x1b\\'); @@ -117,15 +124,7 @@ describe('TerminalImage', () => { }); it('does not re-emit the Kitty sequence when the emit effect re-runs', async () => { - mockedRenderTerminalImage.mockReturnValue({ - kind: 'kitty', - sequence: '\x1b_Gpayload\x1b\\', - placeholder: { - color: '#00002a', - imageId: 42, - lines: ['placeholder'], - }, - }); + mockedRenderTerminalImage.mockReturnValue(KITTY_RESULT); const renderWith = (writer: (...args: unknown[]) => void) => ( @@ -153,4 +152,35 @@ describe('TerminalImage', () => { expect(secondWriteRaw).not.toHaveBeenCalled(); expect(firstWriteRaw).toHaveBeenCalledTimes(1); }); + + it('does not re-transmit the Kitty payload when the image remounts', async () => { + mockedRenderTerminalImage.mockReturnValue(KITTY_RESULT); + + const renderWith = (writer: (...args: unknown[]) => void) => ( + + + + ); + + const firstWriteRaw = vi.fn(); + const first = render(renderWith(firstWriteRaw)); + await vi.waitFor(() => { + expect(firstWriteRaw).toHaveBeenCalledWith('\x1b_Gpayload\x1b\\'); + }); + first.unmount(); + + // A fresh mount (live row -> Static row, or a resize) previously + // re-transmitted the whole payload even though the terminal still holds it. + const secondWriteRaw = vi.fn(); + const second = render(renderWith(secondWriteRaw)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(secondWriteRaw).not.toHaveBeenCalled(); + second.unmount(); + }); }); diff --git a/packages/cli/src/ui/components/TerminalImage.tsx b/packages/cli/src/ui/components/TerminalImage.tsx index d530a86b3e4..36169dfcfcc 100644 --- a/packages/cli/src/ui/components/TerminalImage.tsx +++ b/packages/cli/src/ui/components/TerminalImage.tsx @@ -11,7 +11,9 @@ import type { Config, TerminalImageDisplay } from '@qwen-code/qwen-code-core'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { useTerminalOutput } from '../contexts/TerminalOutputContext.js'; import { + markKittyImageWritten, renderTerminalImage, + wasKittyImageWritten, type TerminalImageRenderResult, } from '../utils/terminal-image-renderer.js'; import { theme } from '../semantic-colors.js'; @@ -34,7 +36,6 @@ export const TerminalImage: React.FC = ({ availableTerminalHeight, }) => { const writeRaw = useTerminalOutput(); - const writtenSequence = React.useRef(null); const filePath = path.resolve(data.filePath); const safePath = config.getWorkspaceContext().isPathWithinWorkspace(filePath); const result = React.useMemo( @@ -53,15 +54,16 @@ export const TerminalImage: React.FC = ({ [availableTerminalHeight, contentWidth, data.mimeType, filePath, safePath], ); - const kittySequence = result?.kind === 'kitty' ? result.sequence : null; + // The Kitty payload is written once per terminal session per render key; the + // terminal keeps the image and redraws it from the placeholder cells, so a + // remount (live row -> Static row, or a resize) must not re-transmit it. React.useEffect(() => { - writtenSequence.current = null; - }, [filePath]); - React.useEffect(() => { - if (!kittySequence || writtenSequence.current === kittySequence) return; - writtenSequence.current = kittySequence; - process.nextTick(() => writeRaw(kittySequence)); - }, [kittySequence, writeRaw]); + if (!result || result.kind !== 'kitty') return; + if (wasKittyImageWritten(result.key)) return; + markKittyImageWritten(result.key); + const sequence = result.sequence; + process.nextTick(() => writeRaw(sequence)); + }, [result, writeRaw]); if (!safePath) { return ( diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts index 19ddcf7dd2f..fed7cfe8400 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.test.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.test.ts @@ -11,8 +11,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { containsCmdShellMetacharacters, getTerminalImageRenderSupport, + markKittyImageWritten, renderTerminalImage, supportsKittyImageProtocol, + TRANSMITTED_KEY_LIMIT, + wasKittyImageWritten, } from './terminal-image-renderer.js'; const PNG_1X1 = Buffer.from( @@ -83,6 +86,53 @@ describe('terminalImageRenderer', () => { expect(result.placeholder.lines[0]).toContain('\u{10EEEE}'); }); + it('exposes a stable render key on Kitty results so remounts can skip re-transmission', async () => { + await fs.writeFile(imagePath, pngWithSize(1600, 800)); + const options = { + display: { + type: 'terminal_image' as const, + filePath: imagePath, + mimeType: 'image/png' as const, + }, + contentWidth: 200, + availableTerminalHeight: 100, + env: { TERM: 'xterm-kitty' }, + stdoutIsTTY: true, + }; + + const first = renderTerminalImage(options); + const second = renderTerminalImage(options); + expect(first.kind).toBe('kitty'); + expect(second.kind).toBe('kitty'); + if (first.kind !== 'kitty' || second.kind !== 'kitty') return; + expect(first.key).toBeTruthy(); + expect(second.key).toBe(first.key); + + // A different placement shape is a different image on the terminal grid and + // therefore gets its own transmission key. + const resized = renderTerminalImage({ ...options, contentWidth: 30 }); + expect(resized.kind).toBe('kitty'); + if (resized.kind === 'kitty') { + expect(resized.key).not.toBe(first.key); + } + }); + + it('tracks transmitted Kitty images for the session and bounds the set', () => { + const unique = `transmit-${Date.now()}-`; + expect(wasKittyImageWritten(`${unique}0`)).toBe(false); + markKittyImageWritten(`${unique}0`); + expect(wasKittyImageWritten(`${unique}0`)).toBe(true); + + // Exceeding the limit evicts the oldest key, so the set stays bounded. + for (let i = 1; i <= TRANSMITTED_KEY_LIMIT; i++) { + markKittyImageWritten(`${unique}${i}`); + } + expect(wasKittyImageWritten(`${unique}0`)).toBe(false); + expect(wasKittyImageWritten(`${unique}${TRANSMITTED_KEY_LIMIT}`)).toBe( + true, + ); + }); + it.each(IMAGE_SIZE_CASES)( '%s', async ( diff --git a/packages/cli/src/ui/utils/terminal-image-renderer.ts b/packages/cli/src/ui/utils/terminal-image-renderer.ts index 7538051a700..b46fb9517ea 100644 --- a/packages/cli/src/ui/utils/terminal-image-renderer.ts +++ b/packages/cli/src/ui/utils/terminal-image-renderer.ts @@ -44,9 +44,35 @@ const RENDER_CACHE_BYTE_LIMIT = 32 * 1024 * 1024; const renderCache = new Map(); let renderCacheBytes = 0; +// A Kitty terminal keeps a transmitted image and redraws it from the placeholder +// cells alone. The live-row -> Static-row move and every resize remount +// TerminalImage, which would re-transmit the same base64 payload (megabytes at +// the size cap) even though the terminal already holds that image id. This +// session-scoped set remembers which render keys were written so a remount only +// re-emits the cheap placeholders. It is bounded so a long session cannot grow +// it without limit; evicting an old key costs at most a re-transmit, never a +// blank image, because the payload is rebuilt from the still-cached render. +export const TRANSMITTED_KEY_LIMIT = 256; +const transmittedKeys = new Set(); + +export function wasKittyImageWritten(key: string): boolean { + return transmittedKeys.has(key); +} + +export function markKittyImageWritten(key: string): void { + transmittedKeys.add(key); + if (transmittedKeys.size > TRANSMITTED_KEY_LIMIT) { + const oldest = transmittedKeys.values().next().value; + if (oldest !== undefined) { + transmittedKeys.delete(oldest); + } + } +} + export type TerminalImageRenderResult = | { kind: 'kitty'; + key: string; sequence: string; placeholder: KittyImagePlaceholder; } @@ -181,7 +207,7 @@ export function renderTerminalImage({ } const result: TerminalImageRenderResult = useKitty - ? renderKitty(png, shape) + ? { ...renderKitty(png, shape), key: cacheKey } : renderWithChafa(display.filePath, shape, env, chafaPath); if (result.kind !== 'unavailable') { rememberRenderResult(cacheKey, result); @@ -345,7 +371,7 @@ function createImageId( function renderKitty( png: Buffer, shape: { widthCells: number; rows: number }, -): Extract { +): Omit, 'key'> { const imageId = createImageId(png, shape); return { kind: 'kitty',