@blockrun/franklin 3.25.0 → 3.25.2

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.
package/dist/ui/app.js CHANGED
@@ -19,6 +19,7 @@ import { estimateCost } from '../pricing.js';
19
19
  import { formatTokens, shortModelName } from '../stats/format.js';
20
20
  import { mouse, forceDisableMouseTracking } from './mouse.js';
21
21
  import { resolveAskUserAnswer } from './ask-user-answer.js';
22
+ import { looksLikeImagePasteStub } from './paste-heuristics.js';
22
23
  // ─── Full-width input box ──────────────────────────────────────────────────
23
24
  const BRACKETED_PASTE_START = '[200~';
24
25
  const BRACKETED_PASTE_END = '[201~';
@@ -445,37 +446,59 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
445
446
  const buffered = pasteBufferRef.current;
446
447
  pasteBufferRef.current = '';
447
448
  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;
449
+ // Image-paste detection. Cmd+V on a clipboard image arrives as an empty
450
+ // bracketed paste on macOS Terminal/iTerm2; several Linux terminals
451
+ // instead emit a filename, a `file://` URI, or the raw image header
452
+ // alongside it (3.25.0 only probed on an empty buffer, so those Linux
453
+ // shapes silently dropped the image fixed in #77). We probe the system
454
+ // clipboard for an image only when the buffer *looks* like one of those
455
+ // stubs; genuine text is inserted synchronously below so the common
456
+ // paste path never waits on the async osascript / xclip / wl-paste
457
+ // shell-out (30-100 ms, but a cold spawn can be more).
458
+ const insertAt = currentCursorOffset;
459
+ const insertPastedText = (buf, baseOffset) => {
460
+ if (buf.length === 0)
461
+ return;
462
+ const lineCount = buf.split('\n').length;
463
+ const textToInsert = lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD
464
+ ? encodePasteBlock(buf)
465
+ : buf;
466
+ const cur = valueRef.current;
467
+ const at = Math.min(baseOffset, cur.length);
468
+ updateValue(cur.slice(0, at) + textToInsert + cur.slice(at), at + textToInsert.length);
469
+ };
470
+ if (looksLikeImagePasteStub(buffered)) {
471
+ // The probe is async; the handler returns now and updateValue happens
472
+ // when the Promise resolves. insertAt (captured above) pins the result
473
+ // to where the user pasted even if the cursor moved meanwhile.
461
474
  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(() => { });
475
+ if (img && 'path' in img) {
476
+ // Image wins drop the bracketed-paste buffer (the terminal stub).
477
+ const injected = encodeImageBlock(img.path);
478
+ const cur = valueRef.current;
479
+ const at = Math.min(insertAt, cur.length);
480
+ updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
481
+ return;
482
+ }
483
+ if (img && 'error' in img) {
484
+ const injected = `[Image rejected: ${img.error}] `;
485
+ const cur = valueRef.current;
486
+ const at = Math.min(insertAt, cur.length);
487
+ updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
488
+ return;
489
+ }
490
+ // No image after all — the stub was literal text (e.g. a lone
491
+ // "photo.png" the user actually typed). Insert it as text.
492
+ insertPastedText(buffered, insertAt);
493
+ }).catch(() => {
494
+ // Probe failed unexpectedly — don't lose the paste; insert as text.
495
+ insertPastedText(buffered, insertAt);
496
+ });
473
497
  return;
474
498
  }
475
- const lineCount = buffered.split('\n').length;
476
- text = lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD
477
- ? encodePasteBlock(buffered)
478
- : buffered;
499
+ // Genuine text paste — insert synchronously, no clipboard probe.
500
+ insertPastedText(buffered, currentCursorOffset);
501
+ return;
479
502
  }
480
503
  if (!text) {
481
504
  if (hasPasteEnd)
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pure heuristics for the bracketed-paste handler in app.tsx, split out so they
3
+ * can be unit-tested without pulling in Ink/React.
4
+ */
5
+ /**
6
+ * Does a bracketed-paste buffer look like a terminal's stand-in for an image
7
+ * paste rather than genuine pasted text?
8
+ *
9
+ * Cmd+V on a clipboard image yields an *empty* buffer on macOS Terminal/iTerm2,
10
+ * but several Linux terminals instead emit a filename, a `file://` URI, or the
11
+ * raw image header alongside the paste. Those shapes — and only those — warrant
12
+ * the (async, 30-100 ms) clipboard probe in app.tsx. Substantial text returns
13
+ * `false` so it can be inserted synchronously, keeping the common paste path
14
+ * instant instead of waiting on an osascript / xclip / wl-paste shell-out.
15
+ *
16
+ * False positives are harmless: a literal text paste of "photo.png" probes the
17
+ * clipboard, finds no image, and falls through to the text path anyway.
18
+ */
19
+ export declare function looksLikeImagePasteStub(buffered: string): boolean;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure heuristics for the bracketed-paste handler in app.tsx, split out so they
3
+ * can be unit-tested without pulling in Ink/React.
4
+ */
5
+ // Image filenames a terminal might substitute for a pasted image. Anchored, so
6
+ // only a string that *is* such a path matches — not prose that mentions one.
7
+ const IMAGE_FILE_EXT = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i;
8
+ /**
9
+ * Does a bracketed-paste buffer look like a terminal's stand-in for an image
10
+ * paste rather than genuine pasted text?
11
+ *
12
+ * Cmd+V on a clipboard image yields an *empty* buffer on macOS Terminal/iTerm2,
13
+ * but several Linux terminals instead emit a filename, a `file://` URI, or the
14
+ * raw image header alongside the paste. Those shapes — and only those — warrant
15
+ * the (async, 30-100 ms) clipboard probe in app.tsx. Substantial text returns
16
+ * `false` so it can be inserted synchronously, keeping the common paste path
17
+ * instant instead of waiting on an osascript / xclip / wl-paste shell-out.
18
+ *
19
+ * False positives are harmless: a literal text paste of "photo.png" probes the
20
+ * clipboard, finds no image, and falls through to the text path anyway.
21
+ */
22
+ export function looksLikeImagePasteStub(buffered) {
23
+ // macOS image paste: empty / whitespace-only bracketed paste.
24
+ if (buffered.trim().length === 0)
25
+ return true;
26
+ // Raw binary the terminal dumped (e.g. PNG's \x1a, or bytes that failed
27
+ // UTF-8 decoding into U+FFFD). Genuine text never carries control chars
28
+ // other than tab / newline / carriage return.
29
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f�]/.test(buffered))
30
+ return true;
31
+ // A single-line file reference the terminal sent instead of the image bytes.
32
+ const trimmed = buffered.trim();
33
+ if (!trimmed.includes('\n')) {
34
+ if (/^file:\/\//i.test(trimmed))
35
+ return true; // file:// URI
36
+ if (IMAGE_FILE_EXT.test(trimmed))
37
+ return true; // bare filename / path
38
+ }
39
+ return false;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.25.0",
3
+ "version": "3.25.2",
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": {