Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 additions & 0 deletions .qwen/e2e-tests/terminal-inline-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 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 successful tool's top-level and nested
`functionResponse.parts`.
4. Confirm the successful tool row retains its images.
5. Open Ctrl+O and resume the session; confirm successful tool image order is
reconstructed from persisted parts. Assistant output resumes its persisted
text; assistant inline images are not persisted by the current Core recorder.
Comment thread
tlysanhuo marked this conversation as resolved.
6. Return six images in one assistant output and one tool response; confirm the
first four render and the row ends with `[+2 more images]`.

### 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. Confirm the oversized payload is
dropped before UI history, while admitted malformed/non-PNG data uses a
deterministic placeholder.
5. Repeat with `INK_SCREEN_READER=true`; confirm only the placeholder is
emitted.
Comment thread
tlysanhuo marked this conversation as 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.
138 changes: 138 additions & 0 deletions docs/design/terminal-inline-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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 assistant PNGs and restored successful tool PNGs through the
#8217 component and renderer;
- render PNGs nested in successful 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;
- render at most four images per assistant output or tool row and collapse the
remainder into a `[+K more images]` marker;
- 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 and are tracked in
#8520.

## 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.
The TUI admits at most four images for one assistant output and represents any
remaining image parts with a small overflow marker. Visible status rows end the
current assistant display block, so later text starts with the normal assistant
prefix instead of being attached to the status row as a continuation.
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.

After a thrown stream, staged output remains transient so an explicit retry can
discard the failed attempt. If an out-of-band shell or slash-command item is
added before the next model submit, that item can enter committed history before
the staged output. The next model submit commits the staged output; history
clear, resume, branch, restore, rewind, and Ctrl+L paths discard it instead.

Resume logic reconstructs ordered text/image runs when persisted parts contain
them. Tool responses retain nested image parts in the session record. The
current Core recorder flattens assistant output to text, so live assistant
images are not restored by `--continue`; assistant-image persistence is outside
this slice and tracked in #8521.

### 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`. Each successful tool row keeps the first four
images and an overflow count for the rest. Failed and cancelled tool records
currently do not carry inline image parts from Core, so their image handling is
defensive rather than a supported output path in this slice.

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, dimensions above 1,000,000 pixels, or images
above 64 million total 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 same encoded-length limit is applied before inline data enters CLI history
or tool-display state. Payloads that exceed the renderer's 8 MiB decoded-image
budget are dropped before rendering and do not produce a placeholder.

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.
The four-image admission cap also bounds synchronous Kitty/chafa rendering for
each assistant output and tool row.

## 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 successful tool responses expose nested images.
- Verify the resume parser preserves assistant text/image ordering for records
that already contain persisted parts; the current recorder's assistant-image
persistence gap remains outside this slice.
- Verify live assistant and successful tool output enforce the image cap and
expose the overflow count; oversized payloads are dropped before UI history.
- Verify memory compaction clears old assistant and tool image payloads.
8 changes: 8 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ describe('AppContainer State Management', () => {
const mockedUseKeypress = useKeypress as Mock;
let originalStdoutIsTTY: boolean | undefined;
let restoreCiEnv = () => {};
let mockClearPendingState: Mock;
const mockedRestorePromptStash = vi.mocked(restorePromptStash);

beforeEach(() => {
Expand All @@ -249,6 +250,7 @@ describe('AppContainer State Management', () => {
capturedUIActions = null!;
capturedRenderMode = 'render';
capturedThoughtExpanded = null!;
mockClearPendingState = vi.fn();

// **Provide a default return value for EVERY mocked hook.**
mockedUseHistory.mockReturnValue({
Expand Down Expand Up @@ -338,6 +340,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
clearPendingState: mockClearPendingState,
});
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
Expand Down Expand Up @@ -1259,6 +1262,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.handleClearScreen();

expect(clearSpy).toHaveBeenCalledTimes(1);
expect(mockClearPendingState).toHaveBeenCalledTimes(1);
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
Expand Down Expand Up @@ -5774,6 +5778,10 @@ describe('AppContainer State Management', () => {
rewindUserItem(1, 'first prompt', 'prompt-1'),
{ id: 2, type: 'gemini', text: 'first response' },
]);
expect(mockClearPendingState).toHaveBeenCalledTimes(1);
expect(mockClearPendingState.mock.invocationCallOrder[0]).toBeLessThan(
harness.loadHistory.mock.invocationCallOrder[0]!,
);
expect(harness.setText).toHaveBeenCalledWith('second prompt');
expect(harness.addItem).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,12 @@ export const AppContainer = (props: AppContainerProps) => {
});
}, [addHistoryItem, config]);

const clearPendingStateRef = useRef<() => void>(() => {});
const clearPendingStateFromRef = useCallback(
() => clearPendingStateRef.current(),
[],
);

const {
isResumeDialogOpen,
resumeMatchedSessions,
Expand All @@ -1508,6 +1514,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings,
historyManager,
startNewSession,
clearPendingState: clearPendingStateFromRef,
setSessionName,
remount: refreshStatic,
});
Expand All @@ -1517,6 +1524,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings,
historyManager,
startNewSession,
clearPendingState: clearPendingStateFromRef,
setSessionName,
remount: refreshStatic,
});
Expand Down Expand Up @@ -1735,6 +1743,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleBranch,
openDeleteDialog,
openHelpDialog,
clearPendingState: () => clearPendingStateRef.current(),
}),
[
openAuthDialog,
Expand Down Expand Up @@ -2044,6 +2053,7 @@ export const AppContainer = (props: AppContainerProps) => {
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems,
clearPendingState,
thought,
cancelOngoingRequest,
preemptGoalTurn,
Expand Down Expand Up @@ -2082,6 +2092,7 @@ export const AppContainer = (props: AppContainerProps) => {
goalQueueRef,
);
cancelOngoingRequestRef.current = cancelOngoingRequest;
clearPendingStateRef.current = clearPendingState;

// Now that streamingState is available, keep isIdleRef in sync and
// flush any deferred update notifications when the model finishes responding.
Expand Down Expand Up @@ -2930,6 +2941,7 @@ export const AppContainer = (props: AppContainerProps) => {
);

const handleClearScreen = useCallback(() => {
clearPendingStateRef.current();
historyManager.clearItems();
clearScreen();
remountStaticHistory();
Expand Down Expand Up @@ -3577,6 +3589,7 @@ export const AppContainer = (props: AppContainerProps) => {
const truncatedUi = expandCollapsedHistory(
originalHistory.filter((h) => h.id < userItem.id),
);
clearPendingStateRef.current();
historyManager.loadHistory(truncatedUi);

refreshStatic();
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/restoreCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('restoreCommand', () => {
config: mockConfig,
},
});
mockContext.ui.clearPendingState = vi.fn();
});

afterEach(async () => {
Expand Down Expand Up @@ -170,6 +171,7 @@ describe('restoreCommand', () => {
expect(mockContext.ui.loadHistory).toHaveBeenCalledWith(
toolCallData.history,
);
expect(mockContext.ui.clearPendingState).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory);
expect(mockRewind).toHaveBeenCalledWith(toolCallData.promptId, true);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/commands/restoreCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ async function restoreAction(
content: 'loadHistory function is not available.',
};
}
context.ui.clearPendingState?.();
loadHistory(toolCallData.history);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface CommandContext {
addItem: UseHistoryManagerReturn['addItem'];
/** Clears all history items and the console screen. */
clear: () => void;
/** Clears transient assistant output before replacing conversation history. */
clearPendingState?: () => void;
/**
* Sets the transient debug message displayed in the application footer in debug mode.
*/
Expand Down
Loading
Loading