@blockrun/franklin 3.24.4 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/ui/app.js +285 -16
  2. package/package.json +1 -1
package/dist/ui/app.js CHANGED
@@ -4,6 +4,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  * Real-time streaming, thinking animation, tool progress, slash commands.
5
5
  */
6
6
  import chalk from 'chalk';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
7
11
  import { useState, useEffect, useCallback, useRef } from 'react';
8
12
  import { render, Static, Box, Text, useApp, useInput, useStdout } from 'ink';
9
13
  import Spinner from 'ink-spinner';
@@ -23,6 +27,16 @@ const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
23
27
  const USER_PROMPT_COLOR = '#FFD700';
24
28
  const PASTE_BLOCK_START = '\uE000PASTE:';
25
29
  const PASTE_BLOCK_END = ':PASTE\uE001';
30
+ // Image attachments work the same way as text paste blocks: the input string
31
+ // carries an encoded token, renderInputValue shows a placeholder, decodePromptValue
32
+ // replaces with the absolute file path when the prompt is submitted. The downstream
33
+ // flow already understands paths \u2014 messageNeedsVision routes to a vision model and
34
+ // the Read tool inlines the bytes \u2014 so an image paste just needs the path injected.
35
+ const IMG_BLOCK_START = '\uE000IMG:';
36
+ const IMG_BLOCK_END = ':IMG\uE001';
37
+ // Clipboard images bigger than this are rejected upfront so a 12MB retina
38
+ // screenshot doesn't sit in /tmp and then fail at Read time. Matches read.ts's cap.
39
+ const MAX_CLIPBOARD_IMG_BYTES = 3_750_000;
26
40
  // Only collapse pastes of >= this many lines into a [Pasted ~N lines] block.
27
41
  // Short pastes (one-liners, 2-4 line snippets) inline as plain text so the
28
42
  // model sees them verbatim and the user can read what they pasted in the
@@ -43,10 +57,13 @@ function normalizeInputNewlines(input) {
43
57
  function encodePasteBlock(content) {
44
58
  return `${PASTE_BLOCK_START}${Buffer.from(content, 'utf8').toString('base64')}${PASTE_BLOCK_END}`;
45
59
  }
46
- function decodePasteBlock(token) {
47
- if (!token.startsWith(PASTE_BLOCK_START) || !token.endsWith(PASTE_BLOCK_END))
60
+ function encodeImageBlock(absolutePath) {
61
+ return `${IMG_BLOCK_START}${Buffer.from(absolutePath, 'utf8').toString('base64')}${IMG_BLOCK_END}`;
62
+ }
63
+ function decodeBlockPayload(token, startMarker, endMarker) {
64
+ if (!token.startsWith(startMarker) || !token.endsWith(endMarker))
48
65
  return token;
49
- const payload = token.slice(PASTE_BLOCK_START.length, -PASTE_BLOCK_END.length);
66
+ const payload = token.slice(startMarker.length, -endMarker.length);
50
67
  try {
51
68
  return Buffer.from(payload, 'base64').toString('utf8');
52
69
  }
@@ -57,15 +74,39 @@ function decodePasteBlock(token) {
57
74
  function findPasteBlocks(value) {
58
75
  const blocks = [];
59
76
  let searchFrom = 0;
77
+ // Scan for both text and image blocks in a single pass, taking whichever
78
+ // starts earlier so they can be interleaved in any order in the input.
60
79
  while (searchFrom < value.length) {
61
- const start = value.indexOf(PASTE_BLOCK_START, searchFrom);
62
- if (start < 0)
80
+ const textStart = value.indexOf(PASTE_BLOCK_START, searchFrom);
81
+ const imgStart = value.indexOf(IMG_BLOCK_START, searchFrom);
82
+ let kind;
83
+ let start;
84
+ let startMarker;
85
+ let endMarker;
86
+ if (textStart < 0 && imgStart < 0)
63
87
  break;
64
- const endMarker = value.indexOf(PASTE_BLOCK_END, start + PASTE_BLOCK_START.length);
65
- if (endMarker < 0)
88
+ if (textStart < 0 || (imgStart >= 0 && imgStart < textStart)) {
89
+ kind = 'image';
90
+ start = imgStart;
91
+ startMarker = IMG_BLOCK_START;
92
+ endMarker = IMG_BLOCK_END;
93
+ }
94
+ else {
95
+ kind = 'text';
96
+ start = textStart;
97
+ startMarker = PASTE_BLOCK_START;
98
+ endMarker = PASTE_BLOCK_END;
99
+ }
100
+ const endIdx = value.indexOf(endMarker, start + startMarker.length);
101
+ if (endIdx < 0)
66
102
  break;
67
- const end = endMarker + PASTE_BLOCK_END.length;
68
- blocks.push({ start, end, content: decodePasteBlock(value.slice(start, end)) });
103
+ const end = endIdx + endMarker.length;
104
+ blocks.push({
105
+ start,
106
+ end,
107
+ kind,
108
+ content: decodeBlockPayload(value.slice(start, end), startMarker, endMarker),
109
+ });
69
110
  searchFrom = end;
70
111
  }
71
112
  return blocks;
@@ -74,13 +115,214 @@ function decodePromptValue(value) {
74
115
  let decoded = '';
75
116
  let cursor = 0;
76
117
  for (const block of findPasteBlocks(value)) {
77
- decoded += value.slice(cursor, block.start) + block.content;
118
+ // Image blocks decode to a bare filesystem path. Pad it with spaces so the
119
+ // path stays a standalone token even when the user typed text flush against
120
+ // the placeholder — otherwise `foo[Image]bar` → `foo/tmp/x.pngbar`, which
121
+ // breaks both the vision-routing regex and the model's path parsing.
122
+ const piece = block.kind === 'image' ? ` ${block.content} ` : block.content;
123
+ decoded += value.slice(cursor, block.start) + piece;
78
124
  cursor = block.end;
79
125
  }
80
126
  return decoded + value.slice(cursor);
81
127
  }
82
- function pasteSummary(content) {
83
- const lines = content.length === 0 ? 0 : content.split('\n').length;
128
+ /**
129
+ * Read the system clipboard, and if it currently holds an image, save it to
130
+ * a temp file and return the absolute path. Otherwise return null.
131
+ *
132
+ * Probed synchronously because it's only called on a paste event where the
133
+ * user is actively waiting — the 30-100 ms shell-out is imperceptible. Bound
134
+ * by a short timeout so a hung clipboard tool can never block the input loop.
135
+ *
136
+ * macOS: `pbpaste -Prefer image` writes the clipboard image to stdout (PNG
137
+ * if available, otherwise nothing/text). Empty stdout means no image.
138
+ * Linux: tries `wl-paste --type image/png` (Wayland) then `xclip -selection
139
+ * clipboard -t image/png -o` (X11). The first one that returns non-empty
140
+ * bytes wins.
141
+ *
142
+ * Files land in $TMPDIR/franklin-clip-<ts>.png. macOS scrubs /tmp on reboot
143
+ * and most Linux distros sweep entries older than 10 days via tmpfiles.d, so
144
+ * we deliberately do NOT add our own cleanup — the OS handles it.
145
+ */
146
+ /**
147
+ * Down-scale an oversize clipboard image so it fits under MAX_CLIPBOARD_IMG_BYTES
148
+ * instead of being rejected. Reuses the same strategy as Read on a .png file
149
+ * (`src/tools/read.ts`): long edge → 1280 px, JPEG q85 (mozjpeg), preserving
150
+ * PNG when there's real transparency. Overwrites the original file in place.
151
+ *
152
+ * Best-effort: if sharp is missing or chokes, we return null and the caller
153
+ * surfaces the original-size rejection rather than silently shipping a 12 MB
154
+ * paste downstream.
155
+ */
156
+ async function shrinkImageInPlace(filePath) {
157
+ try {
158
+ const before = fs.statSync(filePath).size;
159
+ const raw = fs.readFileSync(filePath);
160
+ const sharpMod = await import('sharp');
161
+ const sharp = sharpMod.default;
162
+ const meta = await sharp(raw, { failOn: 'none' }).metadata();
163
+ let hasAlpha = false;
164
+ if (meta.hasAlpha) {
165
+ const stats = await sharp(raw, { failOn: 'none' }).stats();
166
+ const alpha = stats.channels[stats.channels.length - 1];
167
+ hasAlpha = alpha?.min !== undefined && alpha.min < 255;
168
+ }
169
+ const MAX_LONG_EDGE = 1280;
170
+ const longEdge = Math.max(meta.width ?? 0, meta.height ?? 0);
171
+ let pipeline = sharp(raw, { failOn: 'none' });
172
+ if (longEdge > MAX_LONG_EDGE) {
173
+ pipeline = pipeline.resize({
174
+ width: meta.width && meta.width >= (meta.height ?? 0) ? MAX_LONG_EDGE : undefined,
175
+ height: meta.height && meta.height > (meta.width ?? 0) ? MAX_LONG_EDGE : undefined,
176
+ fit: 'inside',
177
+ withoutEnlargement: true,
178
+ });
179
+ }
180
+ const out = hasAlpha
181
+ ? await pipeline.png({ compressionLevel: 9 }).toBuffer()
182
+ : await pipeline.jpeg({ quality: 85, mozjpeg: true }).toBuffer();
183
+ fs.writeFileSync(filePath, out);
184
+ return { from: before, to: out.length };
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ async function tryReadClipboardImage() {
191
+ const filename = `franklin-clip-${Date.now()}-${Math.floor(Math.random() * 1e6)}.png`;
192
+ const out = path.join(os.tmpdir(), filename);
193
+ if (process.platform === 'darwin') {
194
+ // pbpaste does NOT stream image bytes (its -Prefer only takes txt/rtf/ps);
195
+ // the supported path on macOS is AppleScript reading the clipboard as the
196
+ // PNGf class and writing the bytes itself. Returns "ok" / "no" so we can
197
+ // tell the difference between "no image on the clipboard" and "actual error".
198
+ let result;
199
+ try {
200
+ result = execFileSync('osascript', [
201
+ '-e', 'try',
202
+ '-e', `set the_data to the clipboard as «class PNGf»`,
203
+ '-e', `set fp to (open for access POSIX file "${out}" with write permission)`,
204
+ '-e', 'write the_data to fp',
205
+ '-e', 'close access fp',
206
+ '-e', 'return "ok"',
207
+ '-e', 'on error',
208
+ '-e', 'return "no"',
209
+ '-e', 'end try',
210
+ ], { timeout: 1500, encoding: 'utf8' }).trim();
211
+ }
212
+ catch {
213
+ return null; /* osascript missing or hung */
214
+ }
215
+ if (result !== 'ok')
216
+ return null;
217
+ }
218
+ else if (process.platform === 'linux') {
219
+ // wl-paste / xclip both stream image bytes to stdout. Try Wayland first
220
+ // (more common on modern distros), fall back to X11. Either may not be
221
+ // installed — that's fine, we just fall through to the text paste path.
222
+ let buf = null;
223
+ try {
224
+ buf = execFileSync('wl-paste', ['--type', 'image/png'], { timeout: 1500, maxBuffer: 16 * 1024 * 1024 });
225
+ }
226
+ catch { /* try xclip next */ }
227
+ if (!buf || buf.length === 0) {
228
+ try {
229
+ buf = execFileSync('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'], { timeout: 1500, maxBuffer: 16 * 1024 * 1024 });
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ if (!buf || buf.length === 0)
236
+ return null;
237
+ try {
238
+ fs.writeFileSync(out, buf);
239
+ }
240
+ catch (err) {
241
+ return { error: `Failed to save clipboard image: ${err.message}` };
242
+ }
243
+ }
244
+ else {
245
+ return null; // Windows / others not supported yet.
246
+ }
247
+ // Stat + magic-byte check. Cleans up the file if it's not a real image —
248
+ // belt-and-suspenders against osascript writing a weird non-PNG payload, or
249
+ // the clipboard tool returning something that isn't actually an image.
250
+ let stat;
251
+ try {
252
+ stat = fs.statSync(out);
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ if (stat.size === 0) {
258
+ try {
259
+ fs.unlinkSync(out);
260
+ }
261
+ catch { /* ok */ }
262
+ return null;
263
+ }
264
+ let resizedFrom;
265
+ if (stat.size > MAX_CLIPBOARD_IMG_BYTES) {
266
+ // Auto-shrink instead of hard-rejecting — Claude Code went through the
267
+ // same iteration after users hit "Image too large" on retina screenshots.
268
+ const r = await shrinkImageInPlace(out);
269
+ if (!r) {
270
+ try {
271
+ fs.unlinkSync(out);
272
+ }
273
+ catch { /* ok */ }
274
+ return { error: `Image too large (${(stat.size / 1_000_000).toFixed(1)}MB) and could not be resized. Crop or re-save smaller.` };
275
+ }
276
+ resizedFrom = r.from;
277
+ // Re-stat for the post-resize size we'll show in the placeholder.
278
+ try {
279
+ stat = fs.statSync(out);
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ if (stat.size > MAX_CLIPBOARD_IMG_BYTES) {
285
+ // Defensive: if the resize somehow didn't bring it under the cap (highly
286
+ // unusual at 1280px JPEG q85), bail rather than ship an oversize payload.
287
+ try {
288
+ fs.unlinkSync(out);
289
+ }
290
+ catch { /* ok */ }
291
+ return { error: `Image still ${(stat.size / 1_000_000).toFixed(1)}MB after resize. Crop manually.` };
292
+ }
293
+ }
294
+ try {
295
+ const head = fs.readFileSync(out, { encoding: null }).subarray(0, 4);
296
+ const isPng = head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;
297
+ const isJpeg = head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;
298
+ if (!isPng && !isJpeg) {
299
+ try {
300
+ fs.unlinkSync(out);
301
+ }
302
+ catch { /* ok */ }
303
+ return null;
304
+ }
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ return { path: out, bytes: stat.size, resizedFrom };
310
+ }
311
+ function pasteSummary(block) {
312
+ if (block.kind === 'image') {
313
+ // content is the absolute path; show the basename + size hint so the user
314
+ // can tell which image they pasted when they have several.
315
+ let sizeLabel = '';
316
+ try {
317
+ const stat = fs.statSync(block.content);
318
+ sizeLabel = stat.size >= 1024
319
+ ? ` ${(stat.size / 1024).toFixed(0)}KB`
320
+ : ` ${stat.size}B`;
321
+ }
322
+ catch { /* file gone? show without size */ }
323
+ return `[Image${sizeLabel}]`;
324
+ }
325
+ const lines = block.content.length === 0 ? 0 : block.content.split('\n').length;
84
326
  const lineLabel = lines > 1 ? `~${lines} lines` : '~1 line';
85
327
  return `[Pasted ${lineLabel}]`;
86
328
  }
@@ -93,7 +335,7 @@ function renderInputValue(value, cursorOffset, focused) {
93
335
  rendered += renderPlainInputSegment(value.slice(cursor, block.start), cursorOffset - cursor, focused && cursorOffset >= cursor && cursorOffset <= block.start);
94
336
  if (focused && cursorOffset === block.start)
95
337
  rendered += chalk.inverse(' ');
96
- rendered += chalk.hex(USER_PROMPT_COLOR).bold(pasteSummary(block.content));
338
+ rendered += chalk.hex(USER_PROMPT_COLOR).bold(pasteSummary(block));
97
339
  if (focused && cursorOffset === block.end)
98
340
  rendered += chalk.inverse(' ');
99
341
  cursor = block.end;
@@ -201,12 +443,39 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
201
443
  if (!hasPasteEnd)
202
444
  return;
203
445
  const buffered = pasteBufferRef.current;
204
- const lineCount = buffered.length === 0 ? 0 : buffered.split('\n').length;
446
+ pasteBufferRef.current = '';
447
+ pasteActiveRef.current = false;
448
+ // Image-paste detection: terminals deliver Cmd+V as a bracketed paste,
449
+ // but image bytes don't ride that stream — Terminal/iTerm2 just fire an
450
+ // empty (or whitespace-only) bracketed paste, while the actual image
451
+ // sits in the system clipboard. So when the buffered text is empty we
452
+ // probe the clipboard before falling through to plain text handling.
453
+ // (No race vs. real paste content: pasted text always populates the
454
+ // buffer before END arrives, so non-empty buffer = certainly text.)
455
+ if (buffered.trim().length === 0) {
456
+ // Clipboard probe + optional resize are async; the input handler
457
+ // returns now and updateValue happens once the Promise resolves.
458
+ // Capture the cursor offset so the block goes where the user pasted,
459
+ // even if they moved the cursor in the meantime.
460
+ const insertAt = currentCursorOffset;
461
+ tryReadClipboardImage().then((img) => {
462
+ let injected;
463
+ if (img && 'path' in img)
464
+ injected = encodeImageBlock(img.path);
465
+ else if (img && 'error' in img)
466
+ injected = `[Image rejected: ${img.error}] `;
467
+ else
468
+ return; // no image on clipboard — nothing to do
469
+ const cur = valueRef.current;
470
+ const at = Math.min(insertAt, cur.length);
471
+ updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
472
+ }).catch(() => { });
473
+ return;
474
+ }
475
+ const lineCount = buffered.split('\n').length;
205
476
  text = lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD
206
477
  ? encodePasteBlock(buffered)
207
478
  : buffered;
208
- pasteBufferRef.current = '';
209
- pasteActiveRef.current = false;
210
479
  }
211
480
  if (!text) {
212
481
  if (hasPasteEnd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.24.4",
3
+ "version": "3.25.0",
4
4
  "description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {