Skip to content

Commit 14b43dc

Browse files
committed
refactor(tui): read clipboard images through the clipboard service
1 parent f4ab199 commit 14b43dc

7 files changed

Lines changed: 157 additions & 47 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ OpenTUI (`@opentui/core`) is the shipping shell; the Ink/React tree has been del
443443
- **Runner host** (`runner-host.ts`) — Runner-facing mount: catalog assembly from live config, chrome pushes on session change, subagent observe resolution, and session teardown (quitting is Ctrl+C twice, owned by the shell).
444444
- **Overlays and pickers** — Resume picker (`src/tui/pick-session.ts`) uses `runListModal` (`src/tui/list-modal.ts`). Slash-command surfaces (`/model`, `/settings`, `/permissions`, `/plugins`, etc.) route through `openCommandSurface` (`src/tui/command-surfaces.ts`).
445445
- **Auto mode** — Toggled by CLI flags only (`--auto` / `--no-auto`); there is currently no in-session key bound to it.
446-
- **Send-path ingest** — Submit, leftover send, and live steer run `ingestOperatorPrompt` (`src/tui/prompt-attachments.ts`): inline image-path mentions become attachments (`findImagePathMentions` / `imageAttachmentFromPath`); `@file` contents are inlined and `@dir` mentions become a directory summary (`resolveAtMentions` in `mention-resolution.ts`). The mention popup splices paths into the prompt (`spliceMentionCompletion`); it does not send. Ctrl+V / Ctrl+P (and `/paste-image`) attach a macOS clipboard PNG (`attachClipboardImage` in `shell.ts` → `readClipboardImage`); Cmd+V stays text (bracketed paste). Linux/Windows bitmap clipboard paste is not supported.
446+
- **Send-path ingest** — Submit, leftover send, and live steer run `ingestOperatorPrompt` (`src/tui/prompt-attachments.ts`): inline image-path mentions become attachments (`findImagePathMentions` / `imageAttachmentFromPath`); `@file` contents are inlined and `@dir` mentions become a directory summary (`resolveAtMentions` in `mention-resolution.ts`). The mention popup splices paths into the prompt (`spliceMentionCompletion`); it does not send. Ctrl+V / Ctrl+P (and `/paste-image`) attach an image from the system clipboard (`attachClipboardImage` in `shell.ts` → `readClipboardImage`); Cmd+V stays text (bracketed paste). Image clipboard reads go through the OpenTUI clipboard service (PNG, JPEG, GIF, WebP) on platforms whose host backend supports them.
447447

448448
Known keybindings: `Ctrl+C` interrupts the in-flight run, and quits on a second press inside a two-second window.
449449

‎docs/IMPLEMENTATION.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,10 @@ a path into the prompt (`spliceMentionCompletion`). Attachments land at send
235235
time, not at accept time.
236236

237237
Ctrl+V / Ctrl+P and `/paste-image` call `attachClipboardImage` (`shell.ts`) →
238-
`readClipboardImage` (`image-attachments.ts`), which reads a macOS clipboard
239-
PNG via osascript `«class PNGf»`. Cmd+V is terminal bracketed paste and stays
240-
text. Linux and Windows bitmap clipboard paste is a separate product gap; do
241-
not treat bracketed-paste path text as a paste-time image gesture.
238+
`readClipboardImage` (`image-attachments.ts`), which reads PNG, JPEG, GIF, or
239+
WebP through the OpenTUI clipboard service. Cmd+V is terminal bracketed paste
240+
and stays text. Do not treat bracketed-paste path text as a paste-time image
241+
gesture.
242242

243243
## Configuration
244244

‎docs/TUI.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -878,11 +878,11 @@ sent messages. Once a real `paste` event has fired even once, the fallback
878878
heuristic is permanently skipped for the rest of the session
879879
(`src/tui/shell/keys.ts`, the `sawBracketedPaste` guard).
880880

881-
Ctrl+V and Ctrl+P attach a PNG from the macOS clipboard
881+
Ctrl+V and Ctrl+P attach an image from the system clipboard
882882
(`attachClipboardImage` in `src/tui/shell/prompt.ts` → `readClipboardImage` in
883-
`image-attachments.ts`).
884-
Cmd+V stays text (bracketed paste above). Clipboard image attach is
885-
macOS-only; Linux/Windows bitmap clipboard paste is not supported.
883+
`image-attachments.ts`, which reads through the OpenTUI clipboard service).
884+
Cmd+V stays text (bracketed paste above). The host clipboard backend reads
885+
PNG, JPEG, GIF, and WebP where the platform supports image clipboard data.
886886
`/paste-image` is the same attach path.
887887

888888
@-mention path completion opens a popup keyed off the `@token` under the
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { readClipboardImage } from "./image-attachments.js";
4+
5+
const PNG = Buffer.from(
6+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
7+
"base64",
8+
);
9+
10+
describe("readClipboardImage", () => {
11+
test("turns clipboard image bytes into an attachment without a source path", async () => {
12+
const result = await readClipboardImage(() =>
13+
Promise.resolve({
14+
ok: true,
15+
mimeType: "image/png",
16+
bytes: PNG,
17+
}),
18+
);
19+
expect(result.ok).toBe(true);
20+
if (!result.ok) return;
21+
expect(result.attachment.path).toBeUndefined();
22+
expect(result.attachment.contentType).toBe("image/png");
23+
expect(result.attachment.name.startsWith("clipboard-")).toBe(true);
24+
expect(result.attachment.data.byteLength).toBeGreaterThan(0);
25+
});
26+
27+
test("passes a clipboard miss through", async () => {
28+
const result = await readClipboardImage(() =>
29+
Promise.resolve({ ok: false, reason: "no image found on the clipboard" }),
30+
);
31+
expect(result).toEqual({
32+
ok: false,
33+
reason: "no image found on the clipboard",
34+
});
35+
});
36+
});

‎src/tui/image-attachments.ts‎

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { homedir, tmpdir } from "node:os";
33
import { readFile, stat, unlink, writeFile } from "node:fs/promises";
44
import { spawn } from "node:child_process";
55
import type { MessageAttachment } from "@intx/types/runtime";
6+
import {
7+
readSystemClipboardImage,
8+
type ClipboardImageBytes,
9+
} from "./system-clipboard.js";
610

711
export const MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
812

@@ -335,48 +339,39 @@ function replaceExtension(name: string, contentType: string): string {
335339
return `${dot === -1 ? name : name.slice(0, dot)}.${ext}`;
336340
}
337341

338-
export async function readClipboardImage(): Promise<ClipboardImageResult> {
339-
if (process.platform !== "darwin") {
342+
export async function readClipboardImage(
343+
source: () => Promise<ClipboardImageBytes> = readSystemClipboardImage,
344+
): Promise<ClipboardImageResult> {
345+
const read = await source();
346+
if (!read.ok) return read;
347+
348+
const ext = extensionForMime(read.mimeType);
349+
const tmpPath = join(
350+
tmpdir(),
351+
`corbits-clipboard-${process.pid}-${Date.now()}.${ext}`,
352+
);
353+
try {
354+
await writeFile(tmpPath, read.bytes);
355+
const attachment = await imageAttachmentFromPath(tmpPath);
356+
if (!attachment.ok) return attachment;
357+
const { path: _path, ...clipboardAttachment } = attachment.attachment;
340358
return {
341-
ok: false,
342-
reason: "clipboard image paste is currently supported on macOS",
359+
ok: true,
360+
attachment: {
361+
...clipboardAttachment,
362+
name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.${ext}`,
363+
},
343364
};
344-
}
345-
346-
const tmpPath = `/tmp/corbits-clipboard-${process.pid}-${Date.now()}.png`;
347-
const script = `
348-
set outPath to POSIX file ${JSON.stringify(tmpPath)}
349-
try
350-
set pngData to the clipboard as «class PNGf»
351-
set outFile to open for access outPath with write permission
352-
set eof of outFile to 0
353-
write pngData to outFile
354-
close access outFile
355-
on error errMsg
356-
try
357-
close access outPath
358-
end try
359-
error errMsg
360-
end try
361-
`;
362-
363-
const result = await runProcess("osascript", ["-e", script]);
364-
if (result.code !== 0) {
365+
} catch {
366+
return { ok: false, reason: "could not read an image from the clipboard" };
367+
} finally {
365368
await unlink(tmpPath).catch(() => undefined);
366-
return { ok: false, reason: "no PNG image found on the clipboard" };
367369
}
370+
}
368371

369-
const attachment = await imageAttachmentFromPath(tmpPath);
370-
await unlink(tmpPath).catch(() => undefined);
371-
if (!attachment.ok) return attachment;
372-
const { path: _path, ...clipboardAttachment } = attachment.attachment;
373-
return {
374-
ok: true,
375-
attachment: {
376-
...clipboardAttachment,
377-
name: `clipboard-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
378-
},
379-
};
372+
function extensionForMime(mimeType: string): string {
373+
if (mimeType === "image/jpeg") return "jpg";
374+
return mimeType.split("/")[1] ?? "img";
380375
}
381376

382377
export function formatAttachmentSummary(

‎src/tui/system-clipboard.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test";
22
import type { ClipboardService, ClipboardWriteResult } from "@opentui/core";
33
import { withMockedModuleDuring } from "../../testkit/mock-module.js";
44

5-
import { createSystemClipboard } from "./system-clipboard.js";
5+
import {
6+
createSystemClipboard,
7+
readSystemClipboardImage,
8+
} from "./system-clipboard.js";
69

710
const boundary = {
811
capabilities: null,
@@ -88,4 +91,30 @@ describe("system clipboard", () => {
8891
createSystemClipboard(boundary, service).writeText("payload"),
8992
).rejects.toThrow(/host: failed, terminal: local-failure/);
9093
});
94+
95+
test("reads an image representation from the clipboard service", async () => {
96+
const bytes = new Uint8Array([1, 2, 3]);
97+
const seen: string[][] = [];
98+
const image = await readSystemClipboardImage({
99+
read: (options) => {
100+
seen.push([...options.preferredTypes]);
101+
return Promise.resolve({
102+
status: "read",
103+
representation: { mimeType: "image/png", bytes },
104+
});
105+
},
106+
});
107+
expect(seen[0]).toContain("image/png");
108+
expect(image).toEqual({ ok: true, mimeType: "image/png", bytes });
109+
});
110+
111+
test("maps an empty clipboard read to a miss", async () => {
112+
const image = await readSystemClipboardImage({
113+
read: () => Promise.resolve({ status: "empty" }),
114+
});
115+
expect(image).toEqual({
116+
ok: false,
117+
reason: "no image found on the clipboard",
118+
});
119+
});
91120
});

‎src/tui/system-clipboard.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,56 @@ import {
1818

1919
import type { ClipboardPort } from "./copy-path.js";
2020

21+
const IMAGE_PREFERRED_TYPES = [
22+
"image/png",
23+
"image/jpeg",
24+
"image/gif",
25+
"image/webp",
26+
] as const;
27+
28+
export type ClipboardImageBytes =
29+
| { readonly ok: true; readonly mimeType: string; readonly bytes: Uint8Array }
30+
| { readonly ok: false; readonly reason: string };
31+
32+
export async function readSystemClipboardImage(
33+
service: Pick<ClipboardService, "read"> = createClipboard({
34+
host: createHostClipboard(),
35+
terminal: {
36+
remote: false,
37+
writeText: () => ({ status: "not-attempted", capability: "unsupported" }),
38+
clear: () => ({ status: "not-attempted", capability: "unsupported" }),
39+
},
40+
}),
41+
): Promise<ClipboardImageBytes> {
42+
const result = await service.read({
43+
preferredTypes: IMAGE_PREFERRED_TYPES,
44+
});
45+
if (result.status === "read") {
46+
const mimeType = result.representation.mimeType;
47+
if (
48+
!IMAGE_PREFERRED_TYPES.includes(
49+
mimeType as (typeof IMAGE_PREFERRED_TYPES)[number],
50+
)
51+
) {
52+
return { ok: false, reason: "clipboard did not contain an image" };
53+
}
54+
return { ok: true, mimeType, bytes: result.representation.bytes };
55+
}
56+
if (result.status === "empty") {
57+
return { ok: false, reason: "no image found on the clipboard" };
58+
}
59+
if (result.status === "unsupported") {
60+
return {
61+
ok: false,
62+
reason: "clipboard image paste is not supported here",
63+
};
64+
}
65+
if (result.status === "failed") {
66+
return { ok: false, reason: result.error.message };
67+
}
68+
return { ok: false, reason: "could not read an image from the clipboard" };
69+
}
70+
2171
export function createSystemClipboard(
2272
renderer: RendererClipboardBoundary,
2373
service: ClipboardService = createClipboard({

0 commit comments

Comments
 (0)