mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 13:03:42 +00:00
Merge feature/tui-inline-images: Inline image rendering for supported terminals
This commit is contained in:
commit
27e68d856e
12 changed files with 6684 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue