Merge feature/tui-inline-images: Inline image rendering for supported terminals

This commit is contained in:
Mario Zechner 2025-12-13 23:22:31 +01:00
commit 27e68d856e
12 changed files with 6684 additions and 23 deletions

5901
out.cast Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,8 @@
### Added
- **Inline image rendering**: Terminals supporting Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images now render images inline in tool output. Aspect ratio is preserved by querying terminal cell dimensions on startup. Toggle with `/show-images` command or `terminal.showImages` setting. Falls back to text placeholder on unsupported terminals or when disabled. ([#177](https://github.com/badlogic/pi-mono/pull/177) by [@nicobailon](https://github.com/nicobailon))
- **Gemini 3 Pro thinking levels**: Thinking level selector now works with Gemini 3 Pro models. Minimal/low map to Google's LOW, medium/high map to Google's HIGH. ([#176](https://github.com/badlogic/pi-mono/pull/176) by [@markusylisiurunen](https://github.com/markusylisiurunen))
### Fixed

View file

@ -165,6 +165,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| `/compact [instructions]` | Manually compact conversation context |
| `/autocompact` | Toggle automatic context compaction |
| `/theme` | Select color theme |
| `/show-images` | Toggle inline image display (supported terminals only) |
### Editor Features
@ -236,13 +237,17 @@ Run multiple commands before prompting; all outputs are included together.
### Image Support
Include image paths in your message:
**Attaching images:** Include image paths in your message:
```
You: What's in this screenshot? /path/to/image.png
```
Supported: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`
Supported formats: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`
**Inline rendering:** On terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images, images in tool output are rendered inline. On unsupported terminals, a text placeholder is shown instead.
Toggle inline images with `/show-images` or set `terminal.showImages: false` in settings.
---
@ -600,6 +605,9 @@ See [Hooks Documentation](docs/hooks.md) for full API reference.
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000
},
"terminal": {
"showImages": true
}
}
```
@ -609,6 +617,9 @@ See [Hooks Documentation](docs/hooks.md) for full API reference.
- `maxRetries`: Maximum retry attempts. Default: `3`
- `baseDelayMs`: Base delay for exponential backoff (2s, 4s, 8s). Default: `2000`
**Terminal settings:**
- `showImages`: Render images inline in supported terminals. Default: `true`
---
## CLI Reference

View file

@ -18,6 +18,10 @@ export interface SkillsSettings {
enabled?: boolean; // default: true
}
export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
}
export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
@ -33,6 +37,7 @@ export interface Settings {
hooks?: string[]; // Array of hook file paths
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
skills?: SkillsSettings;
terminal?: TerminalSettings;
}
export class SettingsManager {
@ -237,4 +242,16 @@ export class SettingsManager {
this.settings.skills.enabled = enabled;
this.save();
}
getShowImages(): boolean {
return this.settings.terminal?.showImages ?? true;
}
setShowImages(show: boolean): void {
if (!this.settings.terminal) {
this.settings.terminal = {};
}
this.settings.terminal.showImages = show;
this.save();
}
}

View file

@ -1,5 +1,13 @@
import * as os from "node:os";
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import {
Container,
getCapabilities,
getImageDimensions,
Image,
imageFallback,
Spacer,
Text,
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { theme } from "../theme/theme.js";
@ -22,26 +30,32 @@ function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
export interface ToolExecutionOptions {
showImages?: boolean; // default: true (only used if terminal supports images)
}
/**
* Component that renders a tool call with its result (updateable)
*/
export class ToolExecutionComponent extends Container {
private contentText: Text;
private imageComponents: Image[] = [];
private toolName: string;
private args: any;
private expanded = false;
private showImages: boolean;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError: boolean;
details?: any;
};
constructor(toolName: string, args: any) {
constructor(toolName: string, args: any, options: ToolExecutionOptions = {}) {
super();
this.toolName = toolName;
this.args = args;
this.showImages = options.showImages ?? true;
this.addChild(new Spacer(1));
// Content with colored background and padding
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.addChild(this.contentText);
this.updateDisplay();
@ -66,6 +80,11 @@ export class ToolExecutionComponent extends Container {
this.updateDisplay();
}
setShowImages(show: boolean): void {
this.showImages = show;
this.updateDisplay();
}
private updateDisplay(): void {
const bgFn = this.result
? this.result.isError
@ -75,32 +94,56 @@ export class ToolExecutionComponent extends Container {
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
for (const img of this.imageComponents) {
this.removeChild(img);
}
this.imageComponents = [];
if (this.result) {
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
const caps = getCapabilities();
for (const img of imageBlocks) {
// Show inline image only if terminal supports it AND user setting allows it
if (caps.images && this.showImages && img.data && img.mimeType) {
const imageComponent = new Image(
img.data,
img.mimeType,
{ fallbackColor: (s: string) => theme.fg("toolOutput", s) },
{ maxWidthCells: 60 },
);
this.imageComponents.push(imageComponent);
this.addChild(imageComponent);
}
}
}
}
private getTextOutput(): string {
if (!this.result) return "";
// Extract text from content blocks
const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
// Strip ANSI codes and carriage returns from raw output
// (bash may emit colors/formatting, and Windows may include \r)
let output = textBlocks
.map((c: any) => {
let text = stripAnsi(c.text || "").replace(/\r/g, "");
// stripAnsi misses some escape sequences like standalone ESC \ (String Terminator)
// and leaves orphaned fragments from malformed sequences (e.g. TUI output captured to file)
// Clean up: remove ESC + any following char, and control chars except newline/tab
text = text.replace(/\x1b./g, "");
text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
return text;
})
.join("\n");
// Add indicator for images
if (imageBlocks.length > 0) {
const imageIndicators = imageBlocks.map((img: any) => `[Image: ${img.mimeType}]`).join("\n");
const caps = getCapabilities();
// Show text fallback if terminal doesn't support images OR if user disabled inline images
if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
const imageIndicators = imageBlocks
.map((img: any) => {
const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
return imageFallback(img.mimeType, dims);
})
.join("\n");
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
}

View file

@ -12,10 +12,12 @@ import {
CombinedAutocompleteProvider,
type Component,
Container,
getCapabilities,
Input,
Loader,
Markdown,
ProcessTerminal,
SelectList,
Spacer,
Text,
TruncatedText,
@ -52,7 +54,7 @@ import { ThinkingSelectorComponent } from "./components/thinking-selector.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js";
import { getEditorTheme, getMarkdownTheme, getSelectListTheme, onThemeChange, setTheme, theme } from "./theme/theme.js";
export class InteractiveMode {
private session: AgentSession;
@ -160,6 +162,11 @@ export class InteractiveMode {
{ name: "resume", description: "Resume a different session" },
];
// Add image toggle command only if terminal supports images
if (getCapabilities().images) {
slashCommands.push({ name: "show-images", description: "Toggle inline image display" });
}
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
@ -585,6 +592,11 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/show-images") {
this.handleShowImagesCommand();
this.editor.setText("");
return;
}
if (text === "/debug") {
this.handleDebugCommand();
this.editor.setText("");
@ -691,7 +703,9 @@ export class InteractiveMode {
if (content.type === "toolCall") {
if (!this.pendingTools.has(content.id)) {
this.chatContainer.addChild(new Text("", 0, 0));
const component = new ToolExecutionComponent(content.name, content.arguments);
const component = new ToolExecutionComponent(content.name, content.arguments, {
showImages: this.settingsManager.getShowImages(),
});
this.chatContainer.addChild(component);
this.pendingTools.set(content.id, component);
} else {
@ -731,7 +745,9 @@ export class InteractiveMode {
case "tool_execution_start": {
if (!this.pendingTools.has(event.toolCallId)) {
const component = new ToolExecutionComponent(event.toolName, event.args);
const component = new ToolExecutionComponent(event.toolName, event.args, {
showImages: this.settingsManager.getShowImages(),
});
this.chatContainer.addChild(component);
this.pendingTools.set(event.toolCallId, component);
this.ui.requestRender();
@ -958,7 +974,9 @@ export class InteractiveMode {
for (const content of assistantMsg.content) {
if (content.type === "toolCall") {
const component = new ToolExecutionComponent(content.name, content.arguments);
const component = new ToolExecutionComponent(content.name, content.arguments, {
showImages: this.settingsManager.getShowImages(),
});
this.chatContainer.addChild(component);
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
@ -1634,6 +1652,51 @@ export class InteractiveMode {
this.showStatus(`Auto-compaction: ${newState ? "on" : "off"}`);
}
private handleShowImagesCommand(): void {
// Only available if terminal supports images
const caps = getCapabilities();
if (!caps.images) {
this.showWarning("Your terminal does not support inline images");
return;
}
const currentValue = this.settingsManager.getShowImages();
const items = [
{ value: "yes", label: "Yes", description: "Show images inline in terminal" },
{ value: "no", label: "No", description: "Show text placeholder instead" },
];
const selector = new SelectList(items, 5, getSelectListTheme());
selector.setSelectedIndex(currentValue ? 0 : 1);
selector.onSelect = (item) => {
const newValue = item.value === "yes";
this.settingsManager.setShowImages(newValue);
this.showStatus(`Inline images: ${newValue ? "on" : "off"}`);
this.chatContainer.removeChild(selector);
// Update all existing tool execution components with new setting
for (const child of this.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
child.setShowImages(newValue);
}
}
this.ui.setFocus(this.editor);
this.ui.requestRender();
};
selector.onCancel = () => {
this.chatContainer.removeChild(selector);
this.ui.setFocus(this.editor);
this.ui.requestRender();
};
this.chatContainer.addChild(selector);
this.ui.setFocus(selector);
this.ui.requestRender();
}
private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {

View file

@ -8,7 +8,8 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
- **Component-based**: Simple Component interface with render() method
- **Built-in Components**: Text, Input, Editor, Markdown, Loader, SelectList, Spacer
- **Built-in Components**: Text, Input, Editor, Markdown, Loader, SelectList, Spacer, Image
- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
- **Autocomplete Support**: File paths and slash commands
## Quick Start
@ -190,6 +191,24 @@ Empty lines for vertical spacing.
const spacer = new Spacer(2); // 2 empty lines (default: 1)
```
### Image
Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
```typescript
import { Image } from "@mariozechner/pi-tui";
const image = new Image(
base64Data, // base64-encoded image data
"image/png", // MIME type
{ fallbackColor: (s) => s }, // theme for fallback text
{ maxWidthCells: 60 } // optional: limit width
);
tui.addChild(image);
```
Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
## Autocomplete
### CombinedAutocompleteProvider

View file

@ -0,0 +1,78 @@
import {
getCapabilities,
getImageDimensions,
type ImageDimensions,
imageFallback,
renderImage,
} from "../terminal-image.js";
import type { Component } from "../tui.js";
export interface ImageTheme {
fallbackColor: (str: string) => string;
}
export interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
}
export class Image implements Component {
private base64Data: string;
private mimeType: string;
private dimensions: ImageDimensions;
private theme: ImageTheme;
private options: ImageOptions;
private cachedLines?: string[];
private cachedWidth?: number;
constructor(
base64Data: string,
mimeType: string,
theme: ImageTheme,
options: ImageOptions = {},
dimensions?: ImageDimensions,
) {
this.base64Data = base64Data;
this.mimeType = mimeType;
this.theme = theme;
this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
}
invalidate(): void {
this.cachedLines = undefined;
this.cachedWidth = undefined;
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60);
const caps = getCapabilities();
let lines: string[];
if (caps.images) {
const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth });
if (result) {
lines = [result.sequence];
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
}
this.cachedLines = lines;
this.cachedWidth = width;
return lines;
}
}

View file

@ -9,6 +9,7 @@ export {
} from "./autocomplete.js";
// Components
export { Editor, type EditorTheme } from "./components/editor.js";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
export { Input } from "./components/input.js";
export { Loader } from "./components/loader.js";
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
@ -18,6 +19,29 @@ export { Text } from "./components/text.js";
export { TruncatedText } from "./components/truncated-text.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
// Terminal image support
export {
type CellDimensions,
calculateImageRows,
detectCapabilities,
encodeITerm2,
encodeKitty,
getCapabilities,
getCellDimensions,
getGifDimensions,
getImageDimensions,
getJpegDimensions,
getPngDimensions,
getWebpDimensions,
type ImageDimensions,
type ImageProtocol,
type ImageRenderOptions,
imageFallback,
renderImage,
resetCapabilitiesCache,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.js";
export { type Component, Container, TUI } from "./tui.js";
// Utilities
export { truncateToWidth, visibleWidth } from "./utils.js";

View file

@ -0,0 +1,340 @@
export type ImageProtocol = "kitty" | "iterm2" | null;
export interface TerminalCapabilities {
images: ImageProtocol;
trueColor: boolean;
hyperlinks: boolean;
}
export interface CellDimensions {
widthPx: number;
heightPx: number;
}
export interface ImageDimensions {
widthPx: number;
heightPx: number;
}
export interface ImageRenderOptions {
maxWidthCells?: number;
maxHeightCells?: number;
preserveAspectRatio?: boolean;
}
let cachedCapabilities: TerminalCapabilities | null = null;
// Default cell dimensions - updated by TUI when terminal responds to query
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
export function getCellDimensions(): CellDimensions {
return cellDimensions;
}
export function setCellDimensions(dims: CellDimensions): void {
cellDimensions = dims;
}
export function detectCapabilities(): TerminalCapabilities {
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
const term = process.env.TERM?.toLowerCase() || "";
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (termProgram === "ghostty" || term.includes("ghostty")) {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
return { images: "iterm2", trueColor: true, hyperlinks: true };
}
if (termProgram === "vscode") {
return { images: null, trueColor: true, hyperlinks: true };
}
if (termProgram === "alacritty") {
return { images: null, trueColor: true, hyperlinks: true };
}
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: true };
}
export function getCapabilities(): TerminalCapabilities {
if (!cachedCapabilities) {
cachedCapabilities = detectCapabilities();
}
return cachedCapabilities;
}
export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
export function encodeKitty(
base64Data: string,
options: {
columns?: number;
rows?: number;
imageId?: number;
} = {},
): string {
const CHUNK_SIZE = 4096;
const params: string[] = ["a=T", "f=100", "q=2"];
if (options.columns) params.push(`c=${options.columns}`);
if (options.rows) params.push(`r=${options.rows}`);
if (options.imageId) params.push(`i=${options.imageId}`);
if (base64Data.length <= CHUNK_SIZE) {
return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
}
const chunks: string[] = [];
let offset = 0;
let isFirst = true;
while (offset < base64Data.length) {
const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
const isLast = offset + CHUNK_SIZE >= base64Data.length;
if (isFirst) {
chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
isFirst = false;
} else if (isLast) {
chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
} else {
chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
}
offset += CHUNK_SIZE;
}
return chunks.join("");
}
export function encodeITerm2(
base64Data: string,
options: {
width?: number | string;
height?: number | string;
name?: string;
preserveAspectRatio?: boolean;
inline?: boolean;
} = {},
): string {
const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
if (options.width !== undefined) params.push(`width=${options.width}`);
if (options.height !== undefined) params.push(`height=${options.height}`);
if (options.name) {
const nameBase64 = Buffer.from(options.name).toString("base64");
params.push(`name=${nameBase64}`);
}
if (options.preserveAspectRatio === false) {
params.push("preserveAspectRatio=0");
}
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
}
export function calculateImageRows(
imageDimensions: ImageDimensions,
targetWidthCells: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): number {
const targetWidthPx = targetWidthCells * cellDimensions.widthPx;
const scale = targetWidthPx / imageDimensions.widthPx;
const scaledHeightPx = imageDimensions.heightPx * scale;
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
return Math.max(1, rows);
}
export function getPngDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 24) {
return null;
}
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
return null;
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { widthPx: width, heightPx: height };
} catch {
return null;
}
}
export function getJpegDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 2) {
return null;
}
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset < buffer.length - 9) {
if (buffer[offset] !== 0xff) {
offset++;
continue;
}
const marker = buffer[offset + 1];
if (marker >= 0xc0 && marker <= 0xc2) {
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { widthPx: width, heightPx: height };
}
if (offset + 3 >= buffer.length) {
return null;
}
const length = buffer.readUInt16BE(offset + 2);
if (length < 2) {
return null;
}
offset += 2 + length;
}
return null;
} catch {
return null;
}
}
export function getGifDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 10) {
return null;
}
const sig = buffer.slice(0, 6).toString("ascii");
if (sig !== "GIF87a" && sig !== "GIF89a") {
return null;
}
const width = buffer.readUInt16LE(6);
const height = buffer.readUInt16LE(8);
return { widthPx: width, heightPx: height };
} catch {
return null;
}
}
export function getWebpDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 30) {
return null;
}
const riff = buffer.slice(0, 4).toString("ascii");
const webp = buffer.slice(8, 12).toString("ascii");
if (riff !== "RIFF" || webp !== "WEBP") {
return null;
}
const chunk = buffer.slice(12, 16).toString("ascii");
if (chunk === "VP8 ") {
if (buffer.length < 30) return null;
const width = buffer.readUInt16LE(26) & 0x3fff;
const height = buffer.readUInt16LE(28) & 0x3fff;
return { widthPx: width, heightPx: height };
} else if (chunk === "VP8L") {
if (buffer.length < 25) return null;
const bits = buffer.readUInt32LE(21);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
return { widthPx: width, heightPx: height };
} else if (chunk === "VP8X") {
if (buffer.length < 30) return null;
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
return { widthPx: width, heightPx: height };
}
return null;
} catch {
return null;
}
}
export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
if (mimeType === "image/png") {
return getPngDimensions(base64Data);
}
if (mimeType === "image/jpeg") {
return getJpegDimensions(base64Data);
}
if (mimeType === "image/gif") {
return getGifDimensions(base64Data);
}
if (mimeType === "image/webp") {
return getWebpDimensions(base64Data);
}
return null;
}
export function renderImage(
base64Data: string,
imageDimensions: ImageDimensions,
options: ImageRenderOptions = {},
): { sequence: string; rows: number } | null {
const caps = getCapabilities();
if (!caps.images) {
return null;
}
const maxWidth = options.maxWidthCells ?? 80;
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
if (caps.images === "kitty") {
const sequence = encodeKitty(base64Data, { columns: maxWidth, rows });
return { sequence, rows };
}
if (caps.images === "iterm2") {
const sequence = encodeITerm2(base64Data, {
width: maxWidth,
height: "auto",
preserveAspectRatio: options.preserveAspectRatio ?? true,
});
return { sequence, rows };
}
return null;
}
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
const parts: string[] = [];
if (filename) parts.push(filename);
parts.push(`[${mimeType}]`);
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
return `[Image: ${parts.join(" ")}]`;
}

View file

@ -6,6 +6,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Terminal } from "./terminal.js";
import { getCapabilities, setCellDimensions } from "./terminal-image.js";
import { visibleWidth } from "./utils.js";
/**
@ -79,6 +80,8 @@ export class TUI extends Container {
private focusedComponent: Component | null = null;
private renderRequested = false;
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
constructor(terminal: Terminal) {
super();
@ -95,9 +98,21 @@ export class TUI extends Container {
() => this.requestRender(),
);
this.terminal.hideCursor();
this.queryCellSize();
this.requestRender();
}
private queryCellSize(): void {
// Only query if terminal supports images (cell size is only used for image rendering)
if (!getCapabilities().images) {
return;
}
// Query terminal for cell size in pixels: CSI 16 t
// Response format: CSI 6 ; height ; width t
this.cellSizeQueryPending = true;
this.terminal.write("\x1b[16t");
}
stop(): void {
this.terminal.showCursor();
this.terminal.stop();
@ -113,6 +128,14 @@ export class TUI extends Container {
}
private handleInput(data: string): void {
// If we're waiting for cell size response, buffer input and parse
if (this.cellSizeQueryPending) {
this.inputBuffer += data;
const filtered = this.parseCellSizeResponse();
if (filtered.length === 0) return;
data = filtered;
}
// Pass input to focused component (including Ctrl+C)
// The focused component can decide how to handle Ctrl+C
if (this.focusedComponent?.handleInput) {
@ -121,6 +144,56 @@ export class TUI extends Container {
}
}
private parseCellSizeResponse(): string {
// Response format: ESC [ 6 ; height ; width t
// Match the response pattern
const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
const match = this.inputBuffer.match(responsePattern);
if (match) {
const heightPx = parseInt(match[1], 10);
const widthPx = parseInt(match[2], 10);
if (heightPx > 0 && widthPx > 0) {
setCellDimensions({ widthPx, heightPx });
// Invalidate all components so images re-render with correct dimensions
this.invalidate();
this.requestRender();
}
// Remove the response from buffer
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
this.cellSizeQueryPending = false;
}
// Check if we have a partial response starting (wait for more data)
// ESC [ 6 ; ... could be incomplete
const partialPattern = /\x1b\[6;[\d;]*$/;
if (partialPattern.test(this.inputBuffer)) {
return ""; // Wait for more data
}
// Check for any ESC that might be start of response
const escIndex = this.inputBuffer.lastIndexOf("\x1b");
if (escIndex !== -1 && escIndex > this.inputBuffer.length - 10) {
// Might be incomplete escape sequence, wait a bit
// But return any data before it
const before = this.inputBuffer.substring(0, escIndex);
this.inputBuffer = this.inputBuffer.substring(escIndex);
return before;
}
// No response found, return buffered data as user input
const result = this.inputBuffer;
this.inputBuffer = "";
this.cellSizeQueryPending = false; // Give up waiting
return result;
}
private containsImage(line: string): boolean {
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
}
private doRender(): void {
const width = this.terminal.columns;
const height = this.terminal.rows;
@ -182,6 +255,38 @@ export class TUI extends Container {
return;
}
// Check if image lines changed - they require special handling to avoid duplication
// Only force full re-render if image content actually changed, not just because images exist
const imageLineChanged = (() => {
for (let i = firstChanged; i < Math.max(newLines.length, this.previousLines.length); i++) {
const prevLine = this.previousLines[i] || "";
const newLine = newLines[i] || "";
if (this.containsImage(prevLine) || this.containsImage(newLine)) {
if (prevLine !== newLine) return true;
}
}
return false;
})();
if (imageLineChanged) {
let buffer = "\x1b[?2026h"; // Begin synchronized output
// For Kitty protocol, delete all images before re-render
if (getCapabilities().images === "kitty") {
buffer += "\x1b_Ga=d\x1b\\";
}
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = newLines.length - 1;
this.previousLines = newLines;
this.previousWidth = width;
return;
}
// Check if firstChanged is outside the viewport
// cursorRow is the line where cursor is (0-indexed)
// Viewport shows lines from (cursorRow - height + 1) to cursorRow
@ -222,23 +327,25 @@ export class TUI extends Container {
for (let i = firstChanged; i < newLines.length; i++) {
if (i > firstChanged) buffer += "\r\n";
buffer += "\x1b[2K"; // Clear current line
if (visibleWidth(newLines[i]) > width) {
const line = newLines[i];
const isImageLine = this.containsImage(line);
if (!isImageLine && visibleWidth(line) > width) {
// Log all lines to crash file for debugging
const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
const crashData = [
`Crash at ${new Date().toISOString()}`,
`Terminal width: ${width}`,
`Line ${i} visible width: ${visibleWidth(newLines[i])}`,
`Line ${i} visible width: ${visibleWidth(line)}`,
"",
"=== All rendered lines ===",
...newLines.map((line, idx) => `[${idx}] (w=${visibleWidth(line)}) ${line}`),
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
"",
].join("\n");
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
fs.writeFileSync(crashLogPath, crashData);
throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);
}
buffer += newLines[i];
buffer += line;
}
// If we had more lines before, clear them and move cursor back

View file

@ -0,0 +1,56 @@
import { readFileSync } from "fs";
import { Image } from "../src/components/image.js";
import { Spacer } from "../src/components/spacer.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { getCapabilities, getImageDimensions } from "../src/terminal-image.js";
import { TUI } from "../src/tui.js";
const testImagePath = process.argv[2] || "/tmp/test-image.png";
console.log("Terminal capabilities:", getCapabilities());
console.log("Loading image from:", testImagePath);
let imageBuffer: Buffer;
try {
imageBuffer = readFileSync(testImagePath);
} catch (e) {
console.error(`Failed to load image: ${testImagePath}`);
console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]");
process.exit(1);
}
const base64Data = imageBuffer.toString("base64");
const dims = getImageDimensions(base64Data, "image/png");
console.log("Image dimensions:", dims);
console.log("");
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(new Text("Image Rendering Test", 1, 1));
tui.addChild(new Spacer(1));
if (dims) {
tui.addChild(
new Image(base64Data, "image/png", { fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` }, { maxWidthCells: 60 }, dims),
);
} else {
tui.addChild(new Text("Could not parse image dimensions", 1, 0));
}
tui.addChild(new Spacer(1));
tui.addChild(new Text("Press Ctrl+C to exit", 1, 0));
const editor = {
handleInput(data: string) {
if (data.charCodeAt(0) === 3) {
tui.stop();
process.exit(0);
}
},
};
tui.setFocus(editor as any);
tui.start();