feat(coding-agent): clipboard image paste support via Ctrl+V (fixes #419)

This commit is contained in:
Mario Zechner 2026-01-04 01:05:22 +01:00
parent 97bb411988
commit 5c5084481b
8 changed files with 233 additions and 2 deletions

View file

@ -3,9 +3,11 @@
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
*/
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import Clipboard from "@crosscopy/clipboard";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui";
@ -141,6 +143,10 @@ export class InteractiveMode {
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
// Clipboard image tracking: imageId -> temp file path
private clipboardImages = new Map<number, string>();
private clipboardImageCounter = 0;
// Convenience accessors
private get agent() {
return this.session.agent;
@ -291,6 +297,9 @@ export class InteractiveMode {
theme.fg("dim", followUp) +
theme.fg("muted", " to queue follow-up") +
"\n" +
theme.fg("dim", "ctrl+v") +
theme.fg("muted", " to paste image") +
"\n" +
theme.fg("dim", "drop files") +
theme.fg("muted", " to attach");
const header = new Text(`${logo}\n${instructions}`, 1, 0);
@ -819,6 +828,52 @@ export class InteractiveMode {
this.updateEditorBorderColor();
}
};
// Handle clipboard image paste (triggered on Ctrl+V)
this.editor.onPasteImage = () => {
this.handleClipboardImagePaste();
};
}
private async handleClipboardImagePaste(): Promise<void> {
try {
if (!Clipboard.hasImage()) {
return;
}
const imageData = await Clipboard.getImageBinary();
if (!imageData || imageData.length === 0) {
return;
}
// Write to temp file
const imageId = ++this.clipboardImageCounter;
const tmpDir = os.tmpdir();
const fileName = `pi-clipboard-${crypto.randomUUID()}.png`;
const filePath = path.join(tmpDir, fileName);
fs.writeFileSync(filePath, Buffer.from(imageData));
// Store mapping and insert marker
this.clipboardImages.set(imageId, filePath);
this.editor.insertTextAtCursor(`[image #${imageId}]`);
this.ui.requestRender();
} catch {
// Silently ignore clipboard errors (may not have permission, etc.)
}
}
/**
* Replace [image #N] markers with actual file paths and clear the image map.
*/
private replaceImageMarkers(text: string): string {
let result = text;
for (const [imageId, filePath] of this.clipboardImages) {
const marker = `[image #${imageId}]`;
result = result.replace(marker, filePath);
}
this.clipboardImages.clear();
this.clipboardImageCounter = 0;
return result;
}
private setupEditorSubmitHandler(): void {
@ -948,6 +1003,9 @@ export class InteractiveMode {
}
// If streaming, use prompt() with steer behavior
// Replace image markers with actual file paths
text = this.replaceImageMarkers(text);
// This handles hook commands (execute immediately), slash command expansion, and queueing
if (this.session.isStreaming) {
this.editor.addToHistory(text);
@ -2379,6 +2437,7 @@ export class InteractiveMode {
| \`${toggleThinking}\` | Toggle thinking block visibility |
| \`${externalEditor}\` | Edit message in external editor |
| \`${followUp}\` | Queue follow-up message |
| \`Ctrl+V\` | Paste image from clipboard |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
`;