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

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) {