diff --git a/bun.lock b/bun.lock index d45dd0a..31b1edc 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", + "sharp": "^0.34.5", }, "devDependencies": { "@types/bun": "latest", @@ -32,6 +33,58 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], @@ -96,6 +149,8 @@ "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -198,6 +253,10 @@ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], @@ -218,6 +277,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/package.json b/package.json index 826166b..d869753 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "@letta-ai/letta-client": "^1.6.8", "glob": "^13.0.0", "ink-link": "^5.0.0", - "open": "^10.2.0" + "open": "^10.2.0", + "sharp": "^0.34.5" }, "optionalDependencies": { "@vscode/ripgrep": "^1.17.0" diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 663bc36..eec4ba5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -2957,6 +2957,21 @@ export default function App({ setMessageQueue([]); }, []); + // Handle paste errors (e.g., image too large) + const handlePasteError = useCallback( + (message: string) => { + const statusId = uid("status"); + buffersRef.current.byId.set(statusId, { + kind: "status", + id: statusId, + lines: [`⚠️ ${message}`], + }); + buffersRef.current.order.push(statusId); + refreshDerived(); + }, + [refreshDerived], + ); + const handleInterrupt = useCallback(async () => { // If we're executing client-side tools, abort them AND the main stream const hasTrackedTools = @@ -8028,6 +8043,7 @@ Plan file path: ${planFilePath}`; ralphPendingYolo={pendingRalphConfig?.isYolo ?? false} onRalphExit={handleRalphExit} conversationId={conversationId} + onPasteError={handlePasteError} /> diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 9cec6a3..25494d3 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -139,6 +139,7 @@ export function Input({ ralphPendingYolo = false, onRalphExit, conversationId, + onPasteError, }: { visible?: boolean; streaming: boolean; @@ -163,6 +164,7 @@ export function Input({ ralphPendingYolo?: boolean; onRalphExit?: () => void; conversationId?: string; + onPasteError?: (message: string) => void; }) { const [value, setValue] = useState(""); const [escapePressed, setEscapePressed] = useState(false); @@ -815,6 +817,7 @@ export function Input({ focus={!onEscapeCancel} onBangAtEmpty={handleBangAtEmpty} onBackspaceAtEmpty={handleBackspaceAtEmpty} + onPasteError={onPasteError} /> diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index 7b2cf17..7a5037a 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -41,6 +41,11 @@ interface PasteAwareTextInputProps { * Return true to consume the keystroke. */ onBackspaceAtEmpty?: () => boolean; + + /** + * Called when an image paste fails (e.g., image too large). + */ + onPasteError?: (message: string) => void; } function countLines(text: string): number { @@ -122,6 +127,7 @@ export function PasteAwareTextInput({ onCursorMove, onBangAtEmpty, onBackspaceAtEmpty, + onPasteError, }: PasteAwareTextInputProps) { const { internal_eventEmitter } = useStdin(); const [displayValue, setDisplayValue] = useState(value); @@ -209,26 +215,34 @@ export function PasteAwareTextInput({ // Native terminals don't send image data via bracketed paste, so we need // to explicitly check the clipboard when Ctrl+V is pressed. if (key.ctrl && input === "v") { - const clip = tryImportClipboardImageMac(); - if (clip) { - const at = Math.max( - 0, - Math.min(caretOffsetRef.current, displayValueRef.current.length), - ); - const newDisplay = - displayValueRef.current.slice(0, at) + - clip + - displayValueRef.current.slice(at); - displayValueRef.current = newDisplay; - setDisplayValue(newDisplay); - setActualValue(newDisplay); - onChangeRef.current(newDisplay); - const nextCaret = at + clip.length; - setNudgeCursorOffset(nextCaret); - caretOffsetRef.current = nextCaret; - } - // Don't return - let it fall through to normal paste handling - // in case there's also text in the clipboard + // Fire async handler (can't await in useInput callback) + (async () => { + const result = await tryImportClipboardImageMac(); + if (result) { + if ("error" in result) { + // Report the error via callback + onPasteErrorRef.current?.(result.error); + return; + } + // Success - insert the placeholder + const clip = result.placeholder; + const at = Math.max( + 0, + Math.min(caretOffsetRef.current, displayValueRef.current.length), + ); + const newDisplay = + displayValueRef.current.slice(0, at) + + clip + + displayValueRef.current.slice(at); + displayValueRef.current = newDisplay; + setDisplayValue(newDisplay); + setActualValue(newDisplay); + onChangeRef.current(newDisplay); + const nextCaret = at + clip.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + } + })(); return; } @@ -239,27 +253,22 @@ export function PasteAwareTextInput({ const payload = typeof input === "string" ? input : ""; // Translate any image payloads in the paste (OSC 1337, data URLs, file paths) - let translated = translatePasteForImages(payload); - // If paste event carried no text (common for image-only clipboard), try macOS import - if ((!translated || translated.length === 0) && payload.length === 0) { - const clip = tryImportClipboardImageMac(); - if (clip) translated = clip; - } + const translated = translatePasteForImages(payload); - if (translated && translated.length > 0) { - // Insert at current caret position + // Helper to insert translated content + const insertTranslated = (text: string) => { const at = Math.max( 0, Math.min(caretOffsetRef.current, displayValue.length), ); - const isLarge = countLines(translated) > 5 || translated.length > 500; + const isLarge = countLines(text) > 5 || text.length > 500; if (isLarge) { - const pasteId = allocatePaste(translated); - const placeholder = `[Pasted text #${pasteId} +${countLines(translated)} lines]`; + const pasteId = allocatePaste(text); + const placeholder = `[Pasted text #${pasteId} +${countLines(text)} lines]`; const newDisplay = displayValue.slice(0, at) + placeholder + displayValue.slice(at); const newActual = - actualValue.slice(0, at) + translated + actualValue.slice(at); + actualValue.slice(0, at) + text + actualValue.slice(at); setDisplayValue(newDisplay); setActualValue(newActual); onChange(newDisplay); @@ -267,11 +276,11 @@ export function PasteAwareTextInput({ setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } else { - const displayText = sanitizeForDisplay(translated); + const displayText = sanitizeForDisplay(text); const newDisplay = displayValue.slice(0, at) + displayText + displayValue.slice(at); const newActual = - actualValue.slice(0, at) + translated + actualValue.slice(at); + actualValue.slice(0, at) + text + actualValue.slice(at); setDisplayValue(newDisplay); setActualValue(newActual); onChange(newDisplay); @@ -279,6 +288,26 @@ export function PasteAwareTextInput({ setNudgeCursorOffset(nextCaret); caretOffsetRef.current = nextCaret; } + }; + + // If paste event carried no text (common for image-only clipboard), try macOS import + if ((!translated || translated.length === 0) && payload.length === 0) { + // Fire async handler + (async () => { + const clipResult = await tryImportClipboardImageMac(); + if (clipResult) { + if ("error" in clipResult) { + onPasteErrorRef.current?.(clipResult.error); + return; + } + insertTranslated(clipResult.placeholder); + } + })(); + return; + } + + if (translated && translated.length > 0) { + insertTranslated(translated); return; } // If nothing to insert, fall through @@ -288,23 +317,31 @@ export function PasteAwareTextInput({ (key.meta && (input === "v" || input === "V")) || (key.ctrl && key.shift && (input === "v" || input === "V")) ) { - const placeholder = tryImportClipboardImageMac(); - if (placeholder) { - const at = Math.max( - 0, - Math.min(caretOffsetRef.current, displayValue.length), - ); - const newDisplay = - displayValue.slice(0, at) + placeholder + displayValue.slice(at); - const newActual = - actualValue.slice(0, at) + placeholder + actualValue.slice(at); - setDisplayValue(newDisplay); - setActualValue(newActual); - onChange(newDisplay); - const nextCaret = at + placeholder.length; - setNudgeCursorOffset(nextCaret); - caretOffsetRef.current = nextCaret; - } + // Fire async handler + (async () => { + const result = await tryImportClipboardImageMac(); + if (result) { + if ("error" in result) { + onPasteErrorRef.current?.(result.error); + return; + } + const placeholder = result.placeholder; + const at = Math.max( + 0, + Math.min(caretOffsetRef.current, displayValue.length), + ); + const newDisplay = + displayValue.slice(0, at) + placeholder + displayValue.slice(at); + const newActual = + actualValue.slice(0, at) + placeholder + actualValue.slice(at); + setDisplayValue(newDisplay); + setActualValue(newActual); + onChange(newDisplay); + const nextCaret = at + placeholder.length; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + } + })(); } // Backspace on empty input - handle here since handleChange won't fire @@ -330,6 +367,11 @@ export function PasteAwareTextInput({ onBackspaceAtEmptyRef.current = onBackspaceAtEmpty; }, [onBackspaceAtEmpty]); + const onPasteErrorRef = useRef(onPasteError); + useEffect(() => { + onPasteErrorRef.current = onPasteError; + }, [onPasteError]); + // Consolidated raw stdin handler for Option+Arrow navigation and Option+Delete // Uses internal_eventEmitter (Ink's private API) for escape sequences that useInput doesn't parse correctly. // Falls back gracefully if internal_eventEmitter is unavailable (useInput handler above still works for some cases). diff --git a/src/cli/helpers/clipboard.ts b/src/cli/helpers/clipboard.ts index 926b8bf..3a43fde 100644 --- a/src/cli/helpers/clipboard.ts +++ b/src/cli/helpers/clipboard.ts @@ -1,9 +1,22 @@ // Clipboard utilities for detecting and importing images from system clipboard import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, statSync } from "node:fs"; -import { basename, extname, isAbsolute, resolve } from "node:path"; +import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, extname, isAbsolute, join, resolve } from "node:path"; +import { resizeImageIfNeeded } from "./imageResize"; import { allocateImage } from "./pasteRegistry"; +/** + * Result type for clipboard image import. + * - placeholder: Successfully imported, contains [Image #N] + * - error: Failed with an error message + * - null: No image in clipboard + */ +export type ClipboardImageResult = + | { placeholder: string; resized: boolean; width: number; height: number } + | { error: string } + | null; + /** * Copy text to system clipboard * Returns true if successful, false otherwise @@ -158,50 +171,116 @@ export function translatePasteForImages(paste: string): string { return s; } -// Attempt to import an image directly from OS clipboard on macOS via JXA (built-in) -export function tryImportClipboardImageMac(): string | null { +/** + * Read image from macOS clipboard to a temp file. + * Returns the temp file path and UTI, or null if no image in clipboard. + */ +function getClipboardImageToTempFile(): { + tempPath: string; + uti: string; +} | null { if (process.platform !== "darwin") return null; + + const tempPath = join(tmpdir(), `letta-clipboard-${Date.now()}.bin`); + try { + // JXA script that writes clipboard image to temp file and returns UTI + // This avoids stdout buffer limits for large images const jxa = ` ObjC.import('AppKit'); + ObjC.import('Foundation'); (function() { var pb = $.NSPasteboard.generalPasteboard; - var types = ['public.png','public.jpeg','public.tiff','public.heic','public.heif','public.bmp','public.gif','public.svg-image']; + var types = ['public.png','public.jpeg','public.tiff','public.heic','public.heif','public.bmp','public.gif']; for (var i = 0; i < types.length; i++) { var t = types[i]; var d = pb.dataForType(t); - if (d) { - var b64 = d.base64EncodedStringWithOptions(0).js; - return t + '|' + b64; + if (d && d.length > 0) { + d.writeToFileAtomically($('${tempPath}'), true); + return t; } } return ''; })(); `; - const out = execFileSync("osascript", ["-l", "JavaScript", "-e", jxa], { + + const uti = execFileSync("osascript", ["-l", "JavaScript", "-e", jxa], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); - if (!out) return null; - const idx = out.indexOf("|"); - if (idx <= 0) return null; - const uti = out.slice(0, idx); - const b64 = out.slice(idx + 1); - if (!b64) return null; - const map: Record = { - "public.png": "image/png", - "public.jpeg": "image/jpeg", - "public.tiff": "image/tiff", - "public.heic": "image/heic", - "public.heif": "image/heif", - "public.bmp": "image/bmp", - "public.gif": "image/gif", - "public.svg-image": "image/svg+xml", - }; - const mediaType = map[uti] || "image/png"; - const id = allocateImage({ data: b64, mediaType }); - return `[Image #${id}]`; + + if (!uti || !existsSync(tempPath)) return null; + + return { tempPath, uti }; } catch { + // Clean up temp file on error + if (existsSync(tempPath)) { + try { + unlinkSync(tempPath); + } catch {} + } return null; } } + +const UTI_TO_MEDIA_TYPE: Record = { + "public.png": "image/png", + "public.jpeg": "image/jpeg", + "public.tiff": "image/tiff", + "public.heic": "image/heic", + "public.heif": "image/heif", + "public.bmp": "image/bmp", + "public.gif": "image/gif", +}; + +/** + * Import image from macOS clipboard, resize if needed, return placeholder. + * Uses temp file approach to avoid stdout buffer limits. + * Resizes large images to fit within API limits (2048x2048). + */ +export async function tryImportClipboardImageMac(): Promise { + if (process.platform !== "darwin") return null; + + const clipboardResult = getClipboardImageToTempFile(); + if (!clipboardResult) return null; + + const { tempPath, uti } = clipboardResult; + + try { + // Read the temp file + const buffer = readFileSync(tempPath); + + // Clean up temp file immediately after reading + try { + unlinkSync(tempPath); + } catch {} + + const mediaType = UTI_TO_MEDIA_TYPE[uti] || "image/png"; + + // Resize if needed (handles large retina screenshots, HEIC conversion, etc.) + const resized = await resizeImageIfNeeded(buffer, mediaType); + + // Store in registry + const id = allocateImage({ + data: resized.data, + mediaType: resized.mediaType, + }); + + return { + placeholder: `[Image #${id}]`, + resized: resized.resized, + width: resized.width, + height: resized.height, + }; + } catch (err) { + // Clean up temp file on error + if (existsSync(tempPath)) { + try { + unlinkSync(tempPath); + } catch {} + } + + const message = err instanceof Error ? err.message : String(err); + return { error: `Image paste failed: ${message}` }; + } +} diff --git a/src/cli/helpers/imageResize.ts b/src/cli/helpers/imageResize.ts new file mode 100644 index 0000000..0e988f0 --- /dev/null +++ b/src/cli/helpers/imageResize.ts @@ -0,0 +1,90 @@ +// Image resizing utilities for clipboard paste +// Follows Codex CLI's approach (codex-rs/utils/image/src/lib.rs) +import sharp from "sharp"; + +// Conservative limits that work with Anthropic's API (max 8000x8000) +// Codex uses 2048x768, we use 2048x2048 for more flexibility with tall screenshots +export const MAX_IMAGE_WIDTH = 2048; +export const MAX_IMAGE_HEIGHT = 2048; + +export interface ResizeResult { + data: string; // base64 encoded + mediaType: string; + width: number; + height: number; + resized: boolean; +} + +/** + * Resize image if it exceeds MAX_IMAGE_WIDTH or MAX_IMAGE_HEIGHT. + * Uses 'inside' fit to preserve aspect ratio (like Codex's resize behavior). + * Returns original if already within limits and format is supported. + */ +export async function resizeImageIfNeeded( + buffer: Buffer, + inputMediaType: string, +): Promise { + const image = sharp(buffer); + const metadata = await image.metadata(); + const width = metadata.width ?? 0; + const height = metadata.height ?? 0; + const format = metadata.format; + + const needsResize = width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT; + + // Determine if we can pass through the original format + const isPassthroughFormat = format === "png" || format === "jpeg"; + + if (!needsResize && isPassthroughFormat) { + // No resize needed and format is supported - return original bytes + return { + data: buffer.toString("base64"), + mediaType: inputMediaType, + width, + height, + resized: false, + }; + } + + if (needsResize) { + // Resize preserving aspect ratio + // Use 'inside' fit which is equivalent to Codex's resize behavior + const resized = image.resize(MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT, { + fit: "inside", + withoutEnlargement: true, + }); + + // Output as PNG for lossless quality (or JPEG if input was JPEG) + let outputBuffer: Buffer; + let outputMediaType: string; + + if (format === "jpeg") { + // Preserve JPEG format with good quality (Codex uses 85) + outputBuffer = await resized.jpeg({ quality: 85 }).toBuffer(); + outputMediaType = "image/jpeg"; + } else { + // Default to PNG for everything else + outputBuffer = await resized.png().toBuffer(); + outputMediaType = "image/png"; + } + + const resizedMeta = await sharp(outputBuffer).metadata(); + return { + data: outputBuffer.toString("base64"), + mediaType: outputMediaType, + width: resizedMeta.width ?? 0, + height: resizedMeta.height ?? 0, + resized: true, + }; + } + + // No resize needed but format needs conversion (e.g., HEIC, TIFF, etc.) + const outputBuffer = await image.png().toBuffer(); + return { + data: outputBuffer.toString("base64"), + mediaType: "image/png", + width, + height, + resized: false, + }; +}