Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .qwen/e2e-tests/terminal-inline-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Terminal Inline Images E2E Plan

## Baseline

1. Start the interactive CLI from `main`.
2. Return an assistant response containing
`text -> inlineData(image/png) -> text`.
3. Run a tool whose `functionResponse.parts` contains an `image/png`.
4. Confirm that `main` omits the assistant image and reduces tool media to
text, while the separate `display_image` tool from #8217 can display a
workspace PNG path.

The global `qwen` executable is unavailable in the current environment, so the
baseline is grounded in issue #8090 and the unchanged `main` event mapping.

## Verification

### Shared renderer regression

1. Ask the model to call `display_image` for a workspace PNG.
2. In direct Kitty or Ghostty, confirm the existing native preview still
renders.
3. In a non-native terminal with `chafa` installed, confirm the existing ANSI
preview still renders.
4. Confirm the file-path tool retains its workspace, file-size, and PNG
validation behavior.

### Inline assistant and tool images

1. Return a 1x1 PNG between two assistant text parts.
2. Confirm the transcript order is `text -> image -> text`.
3. Return a PNG in a tool's top-level and nested `functionResponse.parts`.
4. Confirm both successful and failed/cancelled tool rows retain their images.
5. Open Ctrl+O and resume the session; confirm assistant and tool image order is
reconstructed from persisted parts.

### Kitty/Ghostty

1. Start the CLI in direct Kitty or Ghostty without tmux or SSH.
2. Repeat the assistant and tool cases.
3. Confirm inline PNGs use the same virtual placement and Unicode placeholders
as `display_image`.
4. Confirm remounting a history row does not retransmit an already-written
payload.

### chafa

1. Start the CLI in Warp, iTerm2, tmux, SSH, or another non-native environment
with `chafa` installed.
2. Repeat the assistant and tool cases.
3. Confirm PNG bytes render as ANSI symbol rows and stay aligned during normal
scrolling.
4. Confirm image data is supplied through stdin and no model-controlled value
is used as a command argument.

### Placeholders and accessibility

1. Repeat without `chafa` and outside direct Kitty/Ghostty.
2. Confirm a valid PNG displays `[image: <width>x<height> png]`.
3. Repeat with malformed base64, a payload above 8 MiB, invalid
IHDR dimensions, and a non-PNG MIME type.
4. Confirm no raw image sequence is written and a deterministic placeholder
remains visible.
5. Repeat with `INK_SCREEN_READER=true`; confirm only the placeholder is
emitted.
Comment thread
tlysanhuo marked this conversation as resolved.
Resolved

### Stream lifecycle

1. Exercise a fresh retry, continuation retry, model fallback, cancellation,
stream boundary, tool call boundary, and displayed goal-state event.
2. Confirm fresh attempts discard all staged image/text runs from the failed
attempt.
3. Confirm continuations preserve partial output.
4. Confirm every normal boundary commits earlier runs before later status or
tool rows.

## Automated Evidence

Record final results for:

- focused CLI renderer, component, stream, tool, resume, and compaction tests;
- Core `Turn`, `display_image`, config, scheduler, and tool tests;
- `npm run lint:ci`;
- `npm run typecheck`;
- `npm run build`;
- `npm run check:serve-fast-path-bundle`.

Real-terminal output remains a reviewer hardware step when the local environment
has no Kitty/Ghostty session. Reusing #8217's renderer means its existing manual
Kitty, Ghostty, cmux, and Warp evidence continues to cover the terminal protocol
layer; this plan focuses new manual verification on the inline-data entry point
and transcript lifecycle.
108 changes: 108 additions & 0 deletions docs/design/terminal-inline-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Terminal Inline Images

## Problem

The interactive CLI drops model `inlineData` image parts at the
`Turn`-to-TUI boundary. Images nested in tool `functionResponse.parts`
survive in model history, but the tool display reduces them to text. As a
result, image-generating models and screenshot-producing tools cannot show
their output in the conversation.

PR #8217 introduced the path-based `display_image` tool and established the
project's terminal image infrastructure: `TerminalImage`,
`terminal-image-renderer`, native Kitty/Ghostty placement, and `chafa`
symbol output. This change extends that infrastructure to in-memory model and
tool image parts instead of adding another renderer.

## Scope

This is the render-and-forget slice requested by issue #8090:

- preserve ordered text and image parts on content events without changing the
existing concatenated `value` contract;
- render live and restored assistant PNGs through the #8217 component and
renderer;
- render PNGs nested in successful, failed, or cancelled tool responses;
- keep text/image ordering across retry, model fallback, cancellation, stream
boundaries, and goal-state events;
- bound retained image payloads during UI history compaction;
- show a deterministic text placeholder when an image cannot be rendered.

Kitty deletion, resize-driven replacement, terminal cell pixel queries, and
global scroll lifecycle ownership remain out of scope.
Comment thread
tlysanhuo marked this conversation as resolved.
Resolved

## Data Flow

### Model output

`ServerGeminiContentEvent.value` remains the concatenated text consumed by
existing clients. When a response chunk contains image `inlineData`, the
event also carries an optional ordered `parts` field containing displayable
non-thought text and image parts.

Only the interactive TUI reads `parts`. It stages text and image history
items in their original order. A fresh retry or model fallback discards the
failed attempt's staged output, while a normal response boundary commits it.
Text-only events keep their existing runtime shape, so non-interactive output,
SDK, ACP, daemon, channel, Web UI, and VS Code consumers continue using
`value` unchanged.

Recorded assistant messages already retain their original parts. Resume logic
reconstructs ordered text/image runs instead of flattening images away.

### Tool output

Tool media is stored in `functionResponse.parts`. A CLI extractor reads image
`inlineData` from top-level and nested response parts. Live scheduler mapping
and resume mapping attach the images to the existing
`IndividualToolCallDisplay`.

Tools carrying images render individually even when their text-only form would
normally collapse into a read/search summary. `ToolMessage` routes the images
through the same `TerminalImage` component used by assistant messages.

## Rendering

The existing #8217 file-path entry point is unchanged. The shared renderer
adds an in-memory PNG entry point that:

1. validates bounded base64 before decoding;
2. verifies the PNG signature and IHDR dimensions;
3. rejects payloads above 8 MiB or dimensions above 1,000,000 pixels;
4. reuses the existing terminal sizing and bounded render cache;
5. uses native Kitty placement in direct Kitty/Ghostty sessions;
6. passes PNG bytes to `chafa` over stdin in other supported environments;
7. returns a text placeholder when rendering is unavailable.

No temporary file is created. The inline payload is never used as a command
argument, and `chafa` receives the same allowlisted environment as the
path-based renderer.

The fallback format is `[image: <width>x<height> png]`. Invalid PNG data
becomes `[image: png]`; unsupported image MIME types retain their sanitized
format label, such as `[image: jpeg]`. Screen-reader mode always uses the text
placeholder and emits no raw image sequence.

The first slice renders validated PNG data only. Other image MIME types remain
visible as deterministic placeholders rather than entering a second protocol
or decoding path.

## Memory

Encoded images are much larger than ordinary history text. UI compaction drops
payloads from old assistant image items while retaining the 20 most recent
items. Cleared images leave a visible marker instead of becoming blank rows.
Tool image payloads participate in the existing tool-result compaction limit.

## Test Plan

- Keep every #8217 renderer and `display_image` test green.
- Verify inline PNG validation, Kitty rendering, `chafa` stdin rendering,
screen-reader output, and unavailable-renderer placeholders.
- Verify `Turn` preserves mixed `text -> image -> text` ordering while
retaining the old `value` and text-only event shape.
- Verify live TUI ordering across retry, fallback, cancellation, stream
boundaries, and goal-state events.
- Verify live and restored tool responses expose nested images.
- Verify restored assistant history preserves text/image ordering.
- Verify memory compaction clears old assistant and tool image payloads.
2 changes: 2 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
)}
<AssistantMessage
text={itemForDisplay.text}
images={itemForDisplay.images}
Comment thread
tlysanhuo marked this conversation as resolved.
Resolved
Comment thread
tlysanhuo marked this conversation as resolved.
Resolved
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
Expand All @@ -308,6 +309,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'gemini_content' && (
<AssistantMessageContent
text={itemForDisplay.text}
images={itemForDisplay.images}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
Expand Down
62 changes: 61 additions & 1 deletion packages/cli/src/ui/components/TerminalImage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Static } from 'ink';
import { Static, useIsScreenReaderEnabled } from 'ink';
import type { Config } from '@qwen-code/qwen-code-core';
import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js';
import {
prepareInlineTerminalImage,
renderTerminalImage,
type TerminalImageRenderResult,
} from '../utils/terminal-image-renderer.js';
Expand All @@ -18,14 +19,24 @@ import { TerminalImage } from './TerminalImage.js';
const { writtenKeys } = vi.hoisted(() => ({ writtenKeys: new Set<string>() }));

vi.mock('../utils/terminal-image-renderer.js', () => ({
prepareInlineTerminalImage: vi.fn(),
renderTerminalImage: vi.fn(),
wasKittyImageWritten: vi.fn((key: string) => writtenKeys.has(key)),
markKittyImageWritten: vi.fn((key: string) => {
writtenKeys.add(key);
}),
}));

vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useIsScreenReaderEnabled: vi.fn(() => false),
};
});

const mockedRenderTerminalImage = vi.mocked(renderTerminalImage);
const mockedPrepareInlineTerminalImage = vi.mocked(prepareInlineTerminalImage);

function configWithWorkspaceResult(isWithinWorkspace: boolean): Config {
return {
Expand All @@ -41,6 +52,11 @@ const IMAGE = {
mimeType: 'image/png' as const,
};

const INLINE_IMAGE = {
data: 'iVBORw0KGgo=',
mimeType: 'image/png',
};

const KITTY_RESULT: TerminalImageRenderResult = {
kind: 'kitty',
key: 'kitty-payload',
Expand Down Expand Up @@ -77,6 +93,7 @@ describe('TerminalImage', () => {
beforeEach(() => {
writtenKeys.clear();
vi.clearAllMocks();
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
});

it('writes trusted Kitty data and renders its placeholder', async () => {
Expand Down Expand Up @@ -183,4 +200,47 @@ describe('TerminalImage', () => {
expect(secondWriteRaw).not.toHaveBeenCalled();
second.unmount();
});

it('renders inline image data through the shared renderer', async () => {
const writeRaw = vi.fn();
mockedPrepareInlineTerminalImage.mockReturnValue({
fallbackText: '[image: 1x1 png]',
result: KITTY_RESULT,
});

const { lastFrame } = render(
<TerminalOutputProvider value={writeRaw}>
<TerminalImage image={INLINE_IMAGE} contentWidth={80} />
</TerminalOutputProvider>,
);

await vi.waitFor(() => {
expect(writeRaw).toHaveBeenCalledWith(KITTY_RESULT.sequence);
});
expect(lastFrame()).toContain('placeholder');
expect(mockedPrepareInlineTerminalImage).toHaveBeenCalledWith(
expect.objectContaining({
data: INLINE_IMAGE.data,
mimeType: INLINE_IMAGE.mimeType,
disabled: false,
}),
);
});

it('uses the deterministic inline placeholder for screen readers', () => {
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
mockedPrepareInlineTerminalImage.mockReturnValue({
fallbackText: '[image: 1x1 png]',
result: null,
});

const { lastFrame } = render(
<TerminalImage image={INLINE_IMAGE} contentWidth={80} />,
);

expect(lastFrame()).toContain('[image: 1x1 png]');
expect(mockedPrepareInlineTerminalImage).toHaveBeenCalledWith(
expect.objectContaining({ disabled: true }),
);
});
});
Loading
Loading