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