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
7 changes: 4 additions & 3 deletions src/agents/pi-embedded-subscribe.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ export function sanitizeToolResult(result: unknown): unknown {
if (type === "image") {
const data = typeof entry.data === "string" ? entry.data : undefined;
const bytes = data ? data.length : undefined;
const cleaned = { ...entry };
delete cleaned.data;
return { ...cleaned, bytes, omitted: true };
// Retain `data` so the TUI can render images inline. The `omitted: true`
// flag tells the model layer to skip this block when building the prompt,
// so the base64 never inflates the token context.
return { ...entry, bytes, omitted: true };
}
return entry;
});
Expand Down
77 changes: 76 additions & 1 deletion src/tui/clipboard-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@ export type ClipboardImage = {
* Attempts to capture an image from the system clipboard.
* Returns the image as base64 + mimeType, or null if no image is on the clipboard.
*
* Supports macOS (pbpaste/osascript) and Linux (xclip).
* Supports macOS (osascript), Linux (xclip or wl-paste on Wayland), and
* Windows (PowerShell + System.Windows.Forms).
*/
export function captureClipboardImage(): ClipboardImage | null {
if (process.platform === "darwin") {
return captureMacOS();
}
if (process.platform === "linux") {
// Wayland: prefer wl-paste when WAYLAND_DISPLAY is set.
if (process.env.WAYLAND_DISPLAY) {
return captureWayland() ?? captureLinux();
}
return captureLinux();
}
if (process.platform === "win32") {
return captureWindows();
}
return null;
}

Expand Down Expand Up @@ -124,3 +132,70 @@ function captureLinux(): ClipboardImage | null {
return null;
}
}

/** Capture clipboard image on Wayland via wl-paste. */
function captureWayland(): ClipboardImage | null {
try {
// Check available MIME types
const types = execSync("wl-paste -l", {
encoding: "utf-8",
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
});
if (!types.includes("image/png")) {
return null;
}
} catch {
return null;
}

try {
const buf = execSync("wl-paste -t image/png", {
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024,
});
if (buf.length === 0) {
return null;
}
return {
base64: buf.toString("base64"),
mimeType: "image/png",
};
} catch {
return null;
}
}

/** Capture clipboard image on Windows via PowerShell + System.Windows.Forms. */
function captureWindows(): ClipboardImage | null {
// PowerShell script: check if clipboard has an image, get it as PNG bytes,
// and write base64 to stdout.
const script = [
"Add-Type -AssemblyName System.Windows.Forms",
"Add-Type -AssemblyName System.Drawing",
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
"if ($null -eq $img) { exit 1 }",
"$ms = New-Object System.IO.MemoryStream",
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
"[Console]::OpenStandardOutput().Write($ms.ToArray(), 0, $ms.Length)",
].join("; ");

try {
const buf = execSync(`powershell -NoProfile -Command "${script}"`, {
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024,
windowsHide: true,
});
if (buf.length === 0) {
return null;
}
return {
base64: buf.toString("base64"),
mimeType: "image/png",
};
} catch {
return null;
}
}
27 changes: 26 additions & 1 deletion src/tui/components/tool-execution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Box, Container, Image, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { formatToolDetail, resolveToolDisplay } from "../../agents/tool-display.js";
import { renderDiff, parseDiffStats, formatDiffStatsLine } from "../diff-renderer.js";
import { linkifyFilePaths } from "../linkify-paths.js";
Expand All @@ -11,6 +11,8 @@ type ToolResultContent = {
mimeType?: string;
bytes?: number;
omitted?: boolean;
/** Base64-encoded image data, retained for inline rendering. */
data?: string;
};

type ToolResult = {
Expand Down Expand Up @@ -61,6 +63,7 @@ export class ToolExecutionComponent extends Container {
private header: Text;
private argsLine: Text;
private output: Markdown;
private imageContainer: Container;
private toolName: string;
private args: unknown;
private result?: ToolResult;
Expand All @@ -78,11 +81,13 @@ export class ToolExecutionComponent extends Container {
this.output = new Markdown("", 0, 0, markdownTheme, {
color: (line) => theme.toolOutput(line),
});
this.imageContainer = new Container();
this.addChild(new Spacer(1));
this.addChild(this.box);
this.box.addChild(this.header);
this.box.addChild(this.argsLine);
this.box.addChild(this.output);
this.box.addChild(this.imageContainer);
this.refresh();
}

Expand Down Expand Up @@ -145,9 +150,29 @@ export class ToolExecutionComponent extends Container {
// Hidden: only the header line, no output body.
if (this.sectionState === "hidden") {
this.output.setText("");
this.imageContainer.clear();
return;
}

// Render inline images from image content blocks that carry base64 data.
// The Image component handles terminal capability detection internally
// (Kitty/iTerm2) and falls back to a text placeholder when unsupported.
this.imageContainer.clear();
if (this.result?.content && !this.isPartial) {
for (const entry of this.result.content) {
if (entry.type === "image" && entry.data) {
const mime = entry.mimeType ?? "image/png";
try {
const img = new Image(entry.data, mime, { fallbackColor: theme.dim });
this.imageContainer.addChild(img);
} catch {
// If Image construction fails, the text placeholder from
// extractText already covers this block.
}
}
}
}

const isExpanded = this.sectionState === "expanded";

if (isDiff) {
Expand Down
25 changes: 11 additions & 14 deletions src/tui/tui-command-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { Component, TUI } from "@earendil-works/pi-tui";
import { copyToClipboard } from "../infra/clipboard.js";
import { createSubsystemLogger } from "../logging/subsystem.js";

const logger = createSubsystemLogger("tui");
Expand All @@ -12,7 +13,7 @@ import { expandMarkdownCommand, findMarkdownCommand } from "../commands/markdown
import type { SessionsPatchResult } from "../gateway/protocol/index.js";
import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts";
import { normalizeAgentId } from "../routing/session-key.js";
import { execSync, spawn } from "node:child_process";
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { helpText, parseCommand } from "./commands.js";
import { undo, listUndoEntries } from "./undo-manager.js";
Expand Down Expand Up @@ -888,14 +889,10 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
}
try {
const proc = spawn(
process.platform === "darwin" ? "pbcopy" : "xclip",
process.platform === "darwin" ? [] : ["-selection", "clipboard"],
{ stdio: ["pipe", "ignore", "ignore"] },
const ok = await copyToClipboard(lastText);
chatLog.addSystem(
ok ? "last response copied to clipboard" : "copy failed: no clipboard tool found",
);
proc.stdin?.write(lastText);
proc.stdin?.end();
chatLog.addSystem("last response copied to clipboard");
} catch (err) {
chatLog.addSystem(`copy failed: ${String(err)}`);
}
Expand Down Expand Up @@ -1238,14 +1235,14 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}

const token = exportSession(payload);
// Copy to clipboard
// Copy to clipboard using the cross-platform clipboard helper
// (pbcopy / xclip / wl-copy / clip.exe / PowerShell fallback).
try {
execSync(
`echo -n "${token}" | pbcopy 2>/dev/null || echo -n "${token}" | xclip -sel clipboard 2>/dev/null || echo -n "${token}" | xsel --clipboard 2>/dev/null`,
{ encoding: "utf-8" },
);
const ok = await copyToClipboard(token);
chatLog.addSystem(
`Session exported to clipboard (${token.length} chars, ${messages.length} messages). Share this token to import on another device.`,
ok
? `Session exported to clipboard (${token.length} chars, ${messages.length} messages). Share this token to import on another device.`
: `Session token (no clipboard tool found):\n${token}`,
);
} catch {
chatLog.addSystem(`Session token:\n${token}`);
Expand Down
Loading
Loading