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
17 changes: 13 additions & 4 deletions src/screens/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { resolveProjectId } from "../utils/project-id.js";
import { MCPManager, loadMCPConfig, type MCPServerConfig } from "../mcp/client.js";
import { seedSystemMessages, MODE_PROMPTS } from "../agent/system-prompt.js";
import { checkForUpdate } from "../utils/update.js";
import { readClipboardImage } from "../utils/clipboard-image.js";
import { readClipboardImage, MAX_IMAGE_BYTES } from "../utils/clipboard-image.js";
import { SessionLedger } from "../agent/session-ledger.js";
import { COMPACTION_PROMPT, extractSummary, MAX_CONSECUTIVE_COMPACT_FAILURES } from "../agent/compaction-prompt.js";
import { compactMessagesForApi } from "../agent/compaction.js";
Expand Down Expand Up @@ -4315,13 +4315,22 @@ export async function runREPL(
// Attach a raw image from the OS clipboard (screenshots have no file
// path, so they never arrive through the terminal's text paste).
if (dialog.active) return;
const img = readClipboardImage();
if (!img) {
pushSystemMsg("No image on the clipboard. (Text pastes with cmd/ctrl+shift+v as usual.)");
const res = readClipboardImage();
if (!res.ok) {
if (res.reason === "too_large") {
const mb = (res.sizeBytes / (1024 * 1024)).toFixed(1);
const cap = (MAX_IMAGE_BYTES / (1024 * 1024)).toFixed();
pushSystemMsg(
`Clipboard image is ${mb}MB, over the ${cap}MB limit — try a smaller crop or window screenshot.`
);
} else {
pushSystemMsg("No image on the clipboard. (Text pastes with cmd/ctrl+shift+v as usual.)");
}
chatLinesDirty = true;
app.requestRender();
return;
}
const img = res.image;
const n = pendingImages.length + 1;
pendingImages.push({ path: `clipboard-${n}.png`, b64: img.b64, mime: img.mime });
field.paste(`[Image: clipboard #${n}] `);
Expand Down
59 changes: 59 additions & 0 deletions src/utils/clipboard-image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import { wrapPng, MAX_IMAGE_BYTES } from "./clipboard-image.js";

const MB = 1024 * 1024;

describe("wrapPng — sized result discrimination", () => {
test("too large → { ok:false, reason:'too_large', sizeBytes } with real byte count", () => {
const size = MAX_IMAGE_BYTES + 5;
const buf = Buffer.alloc(size);
buf.writeUInt8(0x89, 0); // PNG-ish header byte (cosmetic only)

const res = wrapPng(buf);
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (res && !res.ok) {
expect(res.reason).toBe("too_large");
if (res.reason === "too_large") {
expect(res.sizeBytes).toBe(size);
// 8MB + 5 bytes rounds to 8.0 MB at one decimal.
expect((res.sizeBytes / MB).toFixed(1)).toBe("8.0");
}
}
});

test("under the cap → success result with base64 PNG + mime", () => {
const buf = Buffer.alloc(1024, 0x42);
const res = wrapPng(buf);
expect(res).not.toBeNull();
expect(res!.ok).toBe(true);
if (res && res.ok) {
expect(res.image.mime).toBe("image/png");
// base64 of our 1024-byte buffer round-trips cleanly.
expect(Buffer.from(res.image.b64, "base64").length).toBe(1024);
}
});

test("exactly the cap → allowed (boundary is inclusive)", () => {
const buf = Buffer.alloc(MAX_IMAGE_BYTES, 0);
const res = wrapPng(buf);
expect(res).not.toBeNull();
expect(res!.ok).toBe(true);
});

test("one byte over the cap → too_large", () => {
const buf = Buffer.alloc(MAX_IMAGE_BYTES + 1, 0);
const res = wrapPng(buf);
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (res && !res.ok) expect(res.reason).toBe("too_large");
});

test("empty buffer → null (collapsed to reason:'empty' by the caller)", () => {
expect(wrapPng(Buffer.alloc(0))).toBeNull();
});

test("MAX_IMAGE_BYTES is exactly 8 MiB (guard against regressions)", () => {
expect(MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024);
});
});
64 changes: 43 additions & 21 deletions src/utils/clipboard-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,74 @@
* - Linux: wl-paste (Wayland) or xclip (X11)
* - Windows: PowerShell System.Windows.Forms.Clipboard
*
* Returns null when the clipboard has no image or the platform tool is
* missing — callers treat that as "nothing to attach", never an error.
* Returns a tagged result so callers can distinguish "no image at all" from
* "image found but it exceeds the size cap" — the latter is a real, recoverable
* user error (full-screen Retina screenshots routinely blow past 8MB as PNG),
* so we surface it instead of silently treating it like an empty clipboard.
*/

import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // API request-size guard

export interface ClipboardImage {
b64: string;
mime: string;
}

const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // API request-size guard
export type ClipboardImageResult =
| { ok: true; image: ClipboardImage }
| { ok: false; reason: "empty" }
| { ok: false; reason: "too_large"; sizeBytes: number };

/** Build a "too_large" result for a buffer that exceeded the cap. */
function tooLarge(sizeBytes: number): ClipboardImageResult {
return { ok: false, reason: "too_large", sizeBytes };
}

function fromMac(): ClipboardImage | null {
/**
* Wrap raw PNG bytes into a success result, or report it was too large.
*
* Exported (pure, no IO) so the size-discrimination logic can be unit-tested
* without mocking `node:child_process` (mocking that module globally breaks
* other test files that import `spawn`).
*/
export function wrapPng(buf: Buffer): ClipboardImageResult | null {
if (!buf.length) return null;
if (buf.length > MAX_IMAGE_BYTES) return tooLarge(buf.length);
return { ok: true, image: { b64: buf.toString("base64"), mime: "image/png" } };
}

function fromMac(): ClipboardImageResult | null {
// «data PNGf89504E47...» — AppleScript prints the PNG bytes as hex.
const r = spawnSync("osascript", ["-e", "get the clipboard as «class PNGf»"], {
encoding: "utf-8", timeout: 5_000, maxBuffer: 64 * 1024 * 1024,
});
if (r.status !== 0 || !r.stdout) return null;
const m = /«data PNGf([0-9A-Fa-f]+)»/.exec(r.stdout);
if (!m) return null;
const buf = Buffer.from(m[1]!, "hex");
if (!buf.length || buf.length > MAX_IMAGE_BYTES) return null;
return { b64: buf.toString("base64"), mime: "image/png" };
return wrapPng(Buffer.from(m[1]!, "hex"));
}

function fromLinux(): ClipboardImage | null {
function fromLinux(): ClipboardImageResult | null {
for (const [cmd, args] of [
["wl-paste", ["-t", "image/png"]],
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]],
] as const) {
const r = spawnSync(cmd, args as unknown as string[], {
timeout: 5_000, maxBuffer: 64 * 1024 * 1024,
});
if (r.status === 0 && r.stdout && r.stdout.length > 8 && r.stdout.length <= MAX_IMAGE_BYTES) {
return { b64: Buffer.from(r.stdout).toString("base64"), mime: "image/png" };
}
if (r.status !== 0 || !r.stdout || r.stdout.length <= 8) continue;
const res = wrapPng(r.stdout);
if (res) return res;
}
return null;
}

function fromWindows(): ClipboardImage | null {
function fromWindows(): ClipboardImageResult | null {
const tmp = join(tmpdir(), `klaatai-clip-${process.pid}.png`);
const script =
"Add-Type -AssemblyName System.Windows.Forms; " +
Expand All @@ -64,24 +87,23 @@ function fromWindows(): ClipboardImage | null {
if (r.status !== 0 || !r.stdout?.includes("ok")) return null;
try {
const buf = readFileSync(tmp);
if (!buf.length || buf.length > MAX_IMAGE_BYTES) return null;
return { b64: buf.toString("base64"), mime: "image/png" };
return wrapPng(buf);
} catch {
return null;
} finally {
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
}
}

export function readClipboardImage(): ClipboardImage | null {
export function readClipboardImage(): ClipboardImageResult {
try {
switch (process.platform) {
case "darwin": return fromMac();
case "linux": return fromLinux();
case "win32": return fromWindows();
default: return null;
case "darwin": return fromMac() ?? { ok: false, reason: "empty" };
case "linux": return fromLinux() ?? { ok: false, reason: "empty" };
case "win32": return fromWindows() ?? { ok: false, reason: "empty" };
default: return { ok: false, reason: "empty" };
}
} catch {
return null;
return { ok: false, reason: "empty" };
}
}
}
Loading