Skip to content

Commit 5cc1f64

Browse files
committed
feat(ui-pi): paste an image from the clipboard with Ctrl-V
Terminals deliver only text on paste, so Ctrl-V now shells out to the platform clipboard tool (pngpaste / wl-paste / xclip / powershell), reads an image if present, and attaches it to the next prompt as an ImageContent block — submitUserPrompt + agent.prompt already accept images. Detection and base64 assembly are unit-tested; the live clipboard read needs a desktop (none of those tools exist on the CI/dev box).
1 parent 40e4d41 commit 5cc1f64

4 files changed

Lines changed: 183 additions & 9 deletions

File tree

src/agent/agent.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { homedir } from "node:os";
22
import { isAbsolute, join, resolve } from "node:path";
33
import { Agent, type AgentEvent, type AgentMessage } from "@earendil-works/pi-agent-core";
4-
import type { Model } from "@earendil-works/pi-ai";
4+
import type { ImageContent, Model } from "@earendil-works/pi-ai";
55
import { defaultOAuthConfig } from "../auth/cli.js";
66
import { CredentialsStore } from "../auth/credentials.js";
77
import { TokenManager } from "../auth/token-manager.js";
@@ -124,7 +124,10 @@ export interface AgentBundle {
124124
* veto. Callers that originate prompts from real user input should use
125125
* this instead of `agent.prompt()` directly.
126126
*/
127-
submitUserPrompt: (text: string) => Promise<{ submitted: boolean; reason?: string; error?: string }>;
127+
submitUserPrompt: (
128+
text: string,
129+
images?: ImageContent[],
130+
) => Promise<{ submitted: boolean; reason?: string; error?: string }>;
128131
/**
129132
* Set when `--resume` actually loaded a prior session, with its
130133
* timestamp + message count so the welcome banner can say
@@ -467,7 +470,10 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
467470
* accepted the prompt. The agent's own turn lifecycle still emits
468471
* events on bundle.subscribe.
469472
*/
470-
const submitUserPrompt = async (text: string): Promise<{ submitted: boolean; reason?: string; error?: string }> => {
473+
const submitUserPrompt = async (
474+
text: string,
475+
images?: ImageContent[],
476+
): Promise<{ submitted: boolean; reason?: string; error?: string }> => {
471477
const outcome = await hooks.dispatch("UserPromptSubmit", {
472478
event: "UserPromptSubmit",
473479
workingDir: cwd,
@@ -481,7 +487,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
481487
// callers don't await us — they subscribe to bundle.subscribe for the
482488
// streaming events independent of this resolution.
483489
try {
484-
await agent.prompt(text);
490+
await agent.prompt(text, images && images.length > 0 ? images : undefined);
485491
} catch (e) {
486492
// Throws that fire BEFORE agent_start (auth misconfigured, model
487493
// rejected, network refused at the SDK boundary) never produce an

src/ui-pi/app.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { appendFileSync } from "node:fs";
22
import { basename } from "node:path";
33
import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
4+
import type { ImageContent } from "@earendil-works/pi-ai";
45
import {
56
type AutocompleteItem,
67
CombinedAutocompleteProvider,
@@ -24,6 +25,7 @@ import { runPlanFlow } from "../plan/run-flow.js";
2425
import type { ChatState, ToolExecution } from "../types.js";
2526
import { EMPTY_USAGE } from "../types.js";
2627
import { buildAttachmentPrompt, collectAttachments } from "../ui/attachments.js";
28+
import { type ClipboardImage, readClipboardImage } from "../ui/clipboard-image.js";
2729
import { HistoryStore } from "../ui/history-store.js";
2830
import { notifyTurnComplete } from "../ui/notify.js";
2931
import { runShellEscape } from "../ui/shell-escape.js";
@@ -109,6 +111,8 @@ export class App extends Container {
109111
/** Keyboard copy mode: registry of transcript copy boxes + the Ctrl-O picker. */
110112
private readonly copyRegistry = new CopyRegistry();
111113
private copyPickerOverlay: { handle: OverlayHandle; component: CopyPickerOverlay } | undefined;
114+
/** Images pulled off the clipboard with Ctrl-V, attached to the next prompt. */
115+
private pendingImages: ClipboardImage[] = [];
112116

113117
constructor() {
114118
super();
@@ -369,6 +373,23 @@ export class App extends Container {
369373
this.tui?.requestRender();
370374
}
371375

376+
/** Pull an image off the clipboard and stage it for the next prompt. */
377+
private async attachClipboardImage(): Promise<void> {
378+
try {
379+
const image = await readClipboardImage();
380+
if (!image) {
381+
this.statusBar.note("No image on the clipboard (copy one first; needs pngpaste / wl-paste / xclip).");
382+
} else {
383+
this.pendingImages.push(image);
384+
const kb = Math.round((image.data.length * 3) / 4 / 1024);
385+
this.statusBar.note(`📎 image attached (${kb} KB) — send a message to include it.`);
386+
}
387+
} catch {
388+
this.statusBar.note("Couldn't read the clipboard image.");
389+
}
390+
this.tui?.requestRender();
391+
}
392+
372393
/** Dynamic terminal title: cwd basename + a ● marker while a turn runs. */
373394
private setTitle(working: boolean): void {
374395
const dir = basename(this.bundle.toolContext.cwd) || "codebase";
@@ -423,6 +444,12 @@ export class App extends Container {
423444
this.showCopyPickerOverlay();
424445
return { consume: true };
425446
}
447+
// Ctrl-V attaches an image from the system clipboard to the next
448+
// prompt (text paste arrives via bracketed paste, not Ctrl-V).
449+
if (data === "\x16" && this.inputBar) {
450+
void this.attachClipboardImage();
451+
return { consume: true };
452+
}
426453
// Ghost suggestion: Tab on an empty editor accepts it; any other
427454
// keystroke dismisses it (and still reaches the editor).
428455
const ghost = this.suggestionLine.get();
@@ -534,7 +561,8 @@ export class App extends Container {
534561

535562
private async handleSubmitInner(text: string): Promise<void> {
536563
const trimmed = text.trim();
537-
if (!trimmed) return;
564+
// Allow an image-only submit (Ctrl-V then Enter with no text).
565+
if (!trimmed && this.pendingImages.length === 0) return;
538566

539567
// Slash commands and `!cmd` shell escapes bypass the agent and the
540568
// type-ahead queue. They run immediately so the user never has to
@@ -558,6 +586,10 @@ export class App extends Container {
558586
// Claude-Code "type while it works" behavior. The message also lands
559587
// in the transcript so the user sees what they steered with.
560588
if (this.busy) {
589+
if (!trimmed) {
590+
this.statusBar.note("Finish the current turn before sending an image.");
591+
return;
592+
}
561593
const userMsg: AgentMessage = { role: "user", content: trimmed, timestamp: Date.now() };
562594
this.messages.push(userMsg);
563595
this.transcript.appendUserMessage(trimmed);
@@ -585,10 +617,12 @@ export class App extends Container {
585617
this.statusBar.note(`Attached: ${attachments.map((a) => a.relPath).join(", ")}`);
586618
}
587619

588-
const userMsg: AgentMessage = { role: "user", content: trimmed, timestamp: Date.now() };
620+
const imageCount = this.pendingImages.length;
621+
const display = trimmed || (imageCount > 0 ? `📎 ${imageCount} image${imageCount === 1 ? "" : "s"}` : "");
622+
const userMsg: AgentMessage = { role: "user", content: display, timestamp: Date.now() };
589623
this.messages.push(userMsg);
590-
this.transcript.appendUserMessage(trimmed);
591-
this.persistHistory(trimmed);
624+
this.transcript.appendUserMessage(display);
625+
if (trimmed) this.persistHistory(trimmed);
592626

593627
// Glue-router classification: plan-style requests run through the
594628
// plan flow (Q&A → reviewable plan → agent); everything else
@@ -631,8 +665,11 @@ export class App extends Container {
631665
// stderr as a status-bar note. A real error (agent throws before
632666
// reaching agent_start) surfaces as an ErrorCard — otherwise the
633667
// user just sees their prompt land with no response.
668+
// Attach + clear any clipboard images staged with Ctrl-V.
669+
const images = this.pendingImages.length > 0 ? this.pendingImages : undefined;
670+
this.pendingImages = [];
634671
this.bundle
635-
.submitUserPrompt(promptText)
672+
.submitUserPrompt(promptText, images)
636673
.then((result) => {
637674
if (!result.submitted && result.reason) {
638675
this.statusBar.note(`Prompt blocked by hook: ${result.reason}`);

src/ui/clipboard-image.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
import { detectImageCommand, readClipboardImage } from "./clipboard-image.js";
3+
4+
describe("detectImageCommand", () => {
5+
it("uses pngpaste on macOS", () => {
6+
expect(detectImageCommand("darwin", {})).toMatchObject({ cmd: "pngpaste", mimeType: "image/png" });
7+
});
8+
9+
it("uses wl-paste on Wayland Linux", () => {
10+
expect(detectImageCommand("linux", { WAYLAND_DISPLAY: "wayland-0" })?.cmd).toBe("wl-paste");
11+
});
12+
13+
it("uses xclip on X11 Linux", () => {
14+
expect(detectImageCommand("linux", {})?.cmd).toBe("xclip");
15+
});
16+
17+
it("uses powershell on Windows", () => {
18+
expect(detectImageCommand("win32", {})?.cmd).toBe("powershell");
19+
});
20+
21+
it("returns null on unknown platforms", () => {
22+
expect(detectImageCommand("aix" as NodeJS.Platform, {})).toBeNull();
23+
});
24+
});
25+
26+
describe("readClipboardImage", () => {
27+
it("returns an ImageContent block when the tool yields bytes", async () => {
28+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic
29+
const img = await readClipboardImage({
30+
command: { cmd: "x", args: [], mimeType: "image/png" },
31+
run: async () => png,
32+
});
33+
expect(img).toEqual({ type: "image", mimeType: "image/png", data: png.toString("base64") });
34+
});
35+
36+
it("returns null when the clipboard has no image (empty output)", async () => {
37+
const img = await readClipboardImage({
38+
command: { cmd: "x", args: [], mimeType: "image/png" },
39+
run: async () => null,
40+
});
41+
expect(img).toBeNull();
42+
});
43+
44+
it("returns null when no tool is known for the platform", async () => {
45+
expect(await readClipboardImage({ command: null })).toBeNull();
46+
});
47+
});

src/ui/clipboard-image.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { spawn } from "node:child_process";
2+
3+
/**
4+
* Read an image off the system clipboard and return it as an
5+
* ImageContent block the agent can take as input. Terminals don't deliver
6+
* image bytes through paste (bracketed paste is text-only), so we shell
7+
* out to the platform's clipboard tool on demand — the same approach
8+
* Claude Code uses.
9+
*
10+
* Returns null when no tool is installed, the clipboard holds no image,
11+
* or the read fails — callers treat that as "nothing to attach."
12+
*/
13+
14+
export interface ClipboardImage {
15+
type: "image";
16+
mimeType: string;
17+
/** Base64-encoded image bytes. */
18+
data: string;
19+
}
20+
21+
export interface ImageCommand {
22+
cmd: string;
23+
args: string[];
24+
mimeType: string;
25+
}
26+
27+
/**
28+
* Pick the clipboard-image read command for this platform, or null when
29+
* we don't know one. Pure + testable; the caller checks the tool exists.
30+
*/
31+
export function detectImageCommand(
32+
platform: NodeJS.Platform = process.platform,
33+
env: NodeJS.ProcessEnv = process.env,
34+
): ImageCommand | null {
35+
if (platform === "darwin") {
36+
// pngpaste streams the clipboard image as PNG to stdout with `-`.
37+
return { cmd: "pngpaste", args: ["-"], mimeType: "image/png" };
38+
}
39+
if (platform === "linux") {
40+
if (env.WAYLAND_DISPLAY) {
41+
return { cmd: "wl-paste", args: ["--type", "image/png", "--no-newline"], mimeType: "image/png" };
42+
}
43+
return { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-o"], mimeType: "image/png" };
44+
}
45+
if (platform === "win32") {
46+
// PowerShell pulls the clipboard image and writes PNG bytes to stdout.
47+
const ps =
48+
"$i=Get-Clipboard -Format Image; if($i){$ms=New-Object IO.MemoryStream; $i.Save($ms,[Drawing.Imaging.ImageFormat]::Png); [Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}";
49+
return { cmd: "powershell", args: ["-NoProfile", "-Command", ps], mimeType: "image/png" };
50+
}
51+
return null;
52+
}
53+
54+
export interface ReadClipboardDeps {
55+
command?: ImageCommand | null;
56+
/** Inject a spawn for tests. */
57+
run?: (cmd: string, args: string[]) => Promise<Buffer | null>;
58+
}
59+
60+
export async function readClipboardImage(deps: ReadClipboardDeps = {}): Promise<ClipboardImage | null> {
61+
const command = deps.command !== undefined ? deps.command : detectImageCommand();
62+
if (!command) return null;
63+
const run = deps.run ?? runCapture;
64+
const bytes = await run(command.cmd, command.args);
65+
if (!bytes || bytes.length === 0) return null;
66+
return { type: "image", mimeType: command.mimeType, data: bytes.toString("base64") };
67+
}
68+
69+
/** Spawn a command and capture its stdout as a Buffer; null on any failure. */
70+
function runCapture(cmd: string, args: string[]): Promise<Buffer | null> {
71+
return new Promise((resolve) => {
72+
let child: ReturnType<typeof spawn>;
73+
try {
74+
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
75+
} catch {
76+
resolve(null);
77+
return;
78+
}
79+
const chunks: Buffer[] = [];
80+
child.stdout?.on("data", (c: Buffer) => chunks.push(c));
81+
child.on("error", () => resolve(null)); // tool not installed
82+
child.on("close", (code) => resolve(code === 0 && chunks.length > 0 ? Buffer.concat(chunks) : null));
83+
});
84+
}

0 commit comments

Comments
 (0)