@blockrun/franklin 3.25.2 → 3.25.4
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/agent/llm.js +84 -18
- package/dist/commands/start.js +6 -0
- package/dist/ui/app.js +50 -7
- package/dist/ui/vim-input.d.ts +6 -1
- package/dist/ui/vim-input.js +24 -1
- package/package.json +1 -1
package/dist/agent/llm.js
CHANGED
|
@@ -102,6 +102,58 @@ function linkAbortSignal(parent, child) {
|
|
|
102
102
|
function createModelTimeoutError(stage, model, timeoutMs) {
|
|
103
103
|
return new Error(`Model ${stage} timed out after ${timeoutMs}ms on ${model}`);
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Walk a tool-schema object and drop any `enum` whose entries are strings
|
|
107
|
+
* containing "/". Grok's request validator rejects such enums outright (see
|
|
108
|
+
* the call site for the verbatim upstream error). The model still sees the
|
|
109
|
+
* intended values via the tool's description text, so dropping the schema-
|
|
110
|
+
* level constraint is purely a compatibility shim — no behavioral loss.
|
|
111
|
+
*/
|
|
112
|
+
function stripSlashEnumsForGrok(node) {
|
|
113
|
+
if (Array.isArray(node))
|
|
114
|
+
return node.map(stripSlashEnumsForGrok);
|
|
115
|
+
if (!node || typeof node !== 'object')
|
|
116
|
+
return node;
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [k, v] of Object.entries(node)) {
|
|
119
|
+
if (k === 'enum' &&
|
|
120
|
+
Array.isArray(v) &&
|
|
121
|
+
v.some((x) => typeof x === 'string' && x.includes('/'))) {
|
|
122
|
+
continue; // drop the constraint entirely
|
|
123
|
+
}
|
|
124
|
+
out[k] = stripSlashEnumsForGrok(v);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Wrap `fetch()` so that undici's opaque `TypeError: fetch failed` is
|
|
130
|
+
* replaced with the underlying network reason (ECONNRESET, UND_ERR_*,
|
|
131
|
+
* certificate, DNS, etc.). Without this, every transient connection blip
|
|
132
|
+
* surfaces to the user as "Network: fetch failed" with no way to tell
|
|
133
|
+
* whether it's their network, the gateway, or the upstream provider.
|
|
134
|
+
*
|
|
135
|
+
* Verified 2026-06-03: stress-testing claude-sonnet-4.6 reproduces
|
|
136
|
+
* intermittent "fetch failed" (cheetah's report on 3.25.0). With this
|
|
137
|
+
* helper the message becomes e.g. "fetch failed (UND_ERR_SOCKET: other
|
|
138
|
+
* side closed)" which is actionable.
|
|
139
|
+
*/
|
|
140
|
+
async function fetchWithUnwrappedCause(url, init) {
|
|
141
|
+
try {
|
|
142
|
+
return await fetch(url, init);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof Error && err.message === 'fetch failed' && err.cause) {
|
|
146
|
+
const cause = err.cause;
|
|
147
|
+
const detail = cause.code || cause.errno || cause.message;
|
|
148
|
+
if (detail) {
|
|
149
|
+
const enriched = new Error(`fetch failed (${detail})`);
|
|
150
|
+
enriched.cause = err.cause;
|
|
151
|
+
throw enriched;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
105
157
|
async function withAbortableTimeout(work, controller, timeoutError, timeoutMs) {
|
|
106
158
|
if (timeoutMs <= 0)
|
|
107
159
|
return work();
|
|
@@ -416,6 +468,20 @@ export class ModelClient {
|
|
|
416
468
|
if (requestPayload['tool_choice'] !== undefined && modelDoesNotSupportToolChoice(request.model)) {
|
|
417
469
|
delete requestPayload['tool_choice'];
|
|
418
470
|
}
|
|
471
|
+
// ── Grok: strip enum constraints containing "/" from tool schemas ────────
|
|
472
|
+
// Verified 2026-06-03 via Franklin repro: xAI's request validator hard-
|
|
473
|
+
// rejects any tool-schema enum string containing "/", e.g.
|
|
474
|
+
// "[engine_imposed] /properties/endpoint/enum/0: '/' in 'enum' string
|
|
475
|
+
// value is currently not supported"
|
|
476
|
+
// The Surf tools (and a few others) use endpoint paths like
|
|
477
|
+
// "market/ranking" as enum values to constrain the model's choice. The
|
|
478
|
+
// path list is also enumerated in each tool's description text, so the
|
|
479
|
+
// model still sees the legal values — only the schema-level constraint
|
|
480
|
+
// gets dropped. Other providers keep the enum unchanged.
|
|
481
|
+
if (request.model.startsWith('xai/') && Array.isArray(requestPayload['tools'])) {
|
|
482
|
+
const tools = requestPayload['tools'];
|
|
483
|
+
requestPayload['tools'] = tools.map((tool) => stripSlashEnumsForGrok(tool));
|
|
484
|
+
}
|
|
419
485
|
// ── GLM-specific optimizations ───────────────────────────────────────────
|
|
420
486
|
// GLM models work best with temperature=0.8 per official zai spec.
|
|
421
487
|
// Enable thinking mode only for explicit reasoning variants (-thinking-).
|
|
@@ -481,20 +547,20 @@ export class ModelClient {
|
|
|
481
547
|
// session reached ≥3 messages (system + tool + 3 = 5). See issue #73.
|
|
482
548
|
requestPayload = applyAnthropicPromptCaching(requestPayload, request);
|
|
483
549
|
}
|
|
484
|
-
// ──
|
|
485
|
-
//
|
|
486
|
-
// "developer"
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
550
|
+
// ── No client-side system → developer role rewrite for GPT-5/Codex ─────
|
|
551
|
+
// We used to move the top-level `system` field into `messages[0]` with
|
|
552
|
+
// role "developer" for GPT-5/Codex (OpenAI docs say that role gets
|
|
553
|
+
// stronger instruction-following weight). But the BlockRun gateway
|
|
554
|
+
// speaks Anthropic Messages, which only accepts user|assistant in
|
|
555
|
+
// messages[] — the developer-role payload returns HTTP 400 from the
|
|
556
|
+
// gateway's protocol validator BEFORE it ever reaches OpenAI:
|
|
557
|
+
// {"error":{"message":"messages.0.role: Invalid option: expected
|
|
558
|
+
// one of \"user\"|\"assistant\""}}
|
|
559
|
+
// Verified 2026-06-03 via direct curl + Franklin repro: all GPT-5
|
|
560
|
+
// family models (mini/nano/5.4/5.5) were silently failing under
|
|
561
|
+
// headless -p mode. Keep `system` as a top-level field and let the
|
|
562
|
+
// gateway translate to whatever the upstream needs (it already knows
|
|
563
|
+
// gpt-5 expects developer role internally).
|
|
498
564
|
const body = JSON.stringify(requestPayload);
|
|
499
565
|
const endpoint = `${this.apiUrl}/v1/messages`;
|
|
500
566
|
const headers = {
|
|
@@ -515,7 +581,7 @@ export class ModelClient {
|
|
|
515
581
|
const requestController = new AbortController();
|
|
516
582
|
const unlinkAbort = linkAbortSignal(signal, requestController);
|
|
517
583
|
try {
|
|
518
|
-
let response = await withAbortableTimeout(() =>
|
|
584
|
+
let response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
|
|
519
585
|
method: 'POST',
|
|
520
586
|
headers,
|
|
521
587
|
body,
|
|
@@ -530,7 +596,7 @@ export class ModelClient {
|
|
|
530
596
|
yield { kind: 'error', payload: { message: 'Payment signing failed' } };
|
|
531
597
|
return;
|
|
532
598
|
}
|
|
533
|
-
response = await withAbortableTimeout(() =>
|
|
599
|
+
response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
|
|
534
600
|
method: 'POST',
|
|
535
601
|
headers: { ...headers, ...paymentHeader },
|
|
536
602
|
body,
|
|
@@ -580,7 +646,7 @@ export class ModelClient {
|
|
|
580
646
|
if (this.debug) {
|
|
581
647
|
console.error(`[franklin] tool_choice rejected by upstream; retrying without it (model=${request.model})`);
|
|
582
648
|
}
|
|
583
|
-
response = await withAbortableTimeout(() =>
|
|
649
|
+
response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
|
|
584
650
|
method: 'POST',
|
|
585
651
|
headers,
|
|
586
652
|
body: retryBody,
|
|
@@ -592,7 +658,7 @@ export class ModelClient {
|
|
|
592
658
|
yield { kind: 'error', payload: { message: 'Payment signing failed' } };
|
|
593
659
|
return;
|
|
594
660
|
}
|
|
595
|
-
response = await withAbortableTimeout(() =>
|
|
661
|
+
response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
|
|
596
662
|
method: 'POST',
|
|
597
663
|
headers: { ...headers, ...paymentHeader },
|
|
598
664
|
body: retryBody,
|
package/dist/commands/start.js
CHANGED
|
@@ -420,6 +420,12 @@ async function runOneShot(agentConfig, prompt) {
|
|
|
420
420
|
}
|
|
421
421
|
else if (event.kind === 'turn_done') {
|
|
422
422
|
exitCode = oneShotExitCodeForTurnReason(event.reason);
|
|
423
|
+
// Without this, headless callers see exit 1 + zero stderr — impossible
|
|
424
|
+
// to triage. Verified 2026-06-03: GPT-5 family failing with HTTP 400
|
|
425
|
+
// from the gateway looked identical to a network timeout in `-p` mode.
|
|
426
|
+
if (event.reason !== 'completed' && event.error) {
|
|
427
|
+
process.stderr.write(`\n${event.error}\n`);
|
|
428
|
+
}
|
|
423
429
|
process.stdout.write('\n');
|
|
424
430
|
}
|
|
425
431
|
});
|
package/dist/ui/app.js
CHANGED
|
@@ -61,6 +61,21 @@ function encodePasteBlock(content) {
|
|
|
61
61
|
function encodeImageBlock(absolutePath) {
|
|
62
62
|
return `${IMG_BLOCK_START}${Buffer.from(absolutePath, 'utf8').toString('base64')}${IMG_BLOCK_END}`;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Probe the clipboard for an image and return the input-block to splice in at
|
|
66
|
+
* the cursor — an encoded `[IMG:…]` block on success, an inline
|
|
67
|
+
* `[Image rejected: …]` notice if the image was found but unusable, or null
|
|
68
|
+
* when there's no image. Shared by PromptTextInput's Ctrl+V path and VimInput
|
|
69
|
+
* (which renders instead of PromptTextInput in vim mode).
|
|
70
|
+
*/
|
|
71
|
+
async function readClipboardImageInjection() {
|
|
72
|
+
const img = await tryReadClipboardImage();
|
|
73
|
+
if (img && 'path' in img)
|
|
74
|
+
return encodeImageBlock(img.path);
|
|
75
|
+
if (img && 'error' in img)
|
|
76
|
+
return `[Image rejected: ${img.error}] `;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
64
79
|
function decodeBlockPayload(token, startMarker, endMarker) {
|
|
65
80
|
if (!token.startsWith(startMarker) || !token.endsWith(endMarker))
|
|
66
81
|
return token;
|
|
@@ -126,6 +141,15 @@ function decodePromptValue(value) {
|
|
|
126
141
|
}
|
|
127
142
|
return decoded + value.slice(cursor);
|
|
128
143
|
}
|
|
144
|
+
function promptValueForDisplay(value) {
|
|
145
|
+
let rendered = '';
|
|
146
|
+
let cursor = 0;
|
|
147
|
+
for (const block of findPasteBlocks(value)) {
|
|
148
|
+
rendered += value.slice(cursor, block.start) + pasteSummary(block);
|
|
149
|
+
cursor = block.end;
|
|
150
|
+
}
|
|
151
|
+
return rendered + value.slice(cursor);
|
|
152
|
+
}
|
|
129
153
|
/**
|
|
130
154
|
* Read the system clipboard, and if it currently holds an image, save it to
|
|
131
155
|
* a temp file and return the absolute path. Otherwise return null.
|
|
@@ -378,6 +402,15 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
|
|
|
378
402
|
onChange(nextValue);
|
|
379
403
|
setCursorOffset(cursorOffsetRef.current);
|
|
380
404
|
}, [onChange]);
|
|
405
|
+
const insertClipboardImageAt = useCallback((insertAt) => {
|
|
406
|
+
readClipboardImageInjection().then((injected) => {
|
|
407
|
+
if (!injected)
|
|
408
|
+
return; // no image on clipboard — nothing to do
|
|
409
|
+
const cur = valueRef.current;
|
|
410
|
+
const at = Math.min(insertAt, cur.length);
|
|
411
|
+
updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
|
|
412
|
+
}).catch(() => { });
|
|
413
|
+
}, [updateValue]);
|
|
381
414
|
useInput((input, key) => {
|
|
382
415
|
if (!focus)
|
|
383
416
|
return;
|
|
@@ -393,7 +426,7 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
|
|
|
393
426
|
pasteBufferRef.current = '';
|
|
394
427
|
}
|
|
395
428
|
if (key.return && !isPasting) {
|
|
396
|
-
onSubmit(
|
|
429
|
+
onSubmit(currentValue);
|
|
397
430
|
return;
|
|
398
431
|
}
|
|
399
432
|
if (key.home || (key.ctrl && input === 'a')) {
|
|
@@ -434,6 +467,13 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
|
|
|
434
467
|
}
|
|
435
468
|
return;
|
|
436
469
|
}
|
|
470
|
+
// Some Linux terminals do not emit a bracketed-paste event for image-only
|
|
471
|
+
// clipboard contents. Ctrl+V gives users a raw-key fallback that probes the
|
|
472
|
+
// same clipboard image path without relying on terminal paste behavior.
|
|
473
|
+
if (key.ctrl && input === 'v') {
|
|
474
|
+
insertClipboardImageAt(currentCursorOffset);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
437
477
|
if (key.upArrow || key.downArrow || key.tab || key.ctrl || key.meta)
|
|
438
478
|
return;
|
|
439
479
|
let text = normalizeInputNewlines(stripPasteMarkers(input));
|
|
@@ -515,7 +555,7 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
|
|
|
515
555
|
return _jsx(Text, { children: rendered });
|
|
516
556
|
}
|
|
517
557
|
function formatUserPromptForDisplay(value) {
|
|
518
|
-
return `❯ ${
|
|
558
|
+
return `❯ ${promptValueForDisplay(value)}`;
|
|
519
559
|
}
|
|
520
560
|
function disableTerminalAutoWrap() {
|
|
521
561
|
if (!process.stdout.isTTY)
|
|
@@ -601,7 +641,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail
|
|
|
601
641
|
const leadingGlyph = (awaitingApproval || awaitingAnswer)
|
|
602
642
|
? _jsx(Text, { color: "yellow", bold: true, children: "\u26A0 " })
|
|
603
643
|
: (showSpinner ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null);
|
|
604
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: borderColor, borderDimColor: !borderColor, paddingX: 1, width: boxWidth, children: [leadingGlyph, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange })) : (_jsx(PromptTextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", (() => {
|
|
644
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: borderColor, borderDimColor: !borderColor, paddingX: 1, width: boxWidth, children: [leadingGlyph, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange, onClipboardImage: readClipboardImageInjection })) : (_jsx(PromptTextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", (() => {
|
|
605
645
|
// Color the balance by funding state. Real session 2026-05-04
|
|
606
646
|
// had a user staring at "$0.08 USDC" in dim text wondering
|
|
607
647
|
// whether it meant "out of money" or "wrong chain". Make
|
|
@@ -1042,17 +1082,20 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
1042
1082
|
turnTierRef.current = undefined;
|
|
1043
1083
|
turnSavingsRef.current = undefined;
|
|
1044
1084
|
turnCtxPctRef.current = undefined;
|
|
1045
|
-
onSubmit(lastPrompt);
|
|
1085
|
+
onSubmit(decodePromptValue(lastPrompt).trim());
|
|
1046
1086
|
return;
|
|
1047
1087
|
default:
|
|
1048
|
-
// All other slash commands pass through to the agent loop's command registry
|
|
1088
|
+
// All other slash commands pass through to the agent loop's command registry.
|
|
1089
|
+
// Decode here too: a slash command can carry an encoded paste/image block
|
|
1090
|
+
// as an argument, and the registry expects real text / file paths,
|
|
1091
|
+
// not the encoded block sentinels.
|
|
1049
1092
|
setStreamText('');
|
|
1050
1093
|
setThinking(false);
|
|
1051
1094
|
setThinkingText('');
|
|
1052
1095
|
setTools(new Map());
|
|
1053
1096
|
setWaiting(true);
|
|
1054
1097
|
setReady(false);
|
|
1055
|
-
onSubmit(trimmed);
|
|
1098
|
+
onSubmit(decodePromptValue(trimmed).trim());
|
|
1056
1099
|
return;
|
|
1057
1100
|
}
|
|
1058
1101
|
}
|
|
@@ -1091,7 +1134,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
1091
1134
|
turnTierRef.current = undefined;
|
|
1092
1135
|
turnSavingsRef.current = undefined;
|
|
1093
1136
|
turnCtxPctRef.current = undefined;
|
|
1094
|
-
onSubmit(trimmed);
|
|
1137
|
+
onSubmit(decodePromptValue(trimmed).trim());
|
|
1095
1138
|
}, [ready, currentModel, totalCost, onSubmit, onModelChange, requestExit, lastPrompt, inputHistory, showStatus]);
|
|
1096
1139
|
// Mouse support — OFF by default because Node stdin is shared: mouse escape
|
|
1097
1140
|
// sequences leak into Ink's input handler as typed text. Opt in with
|
package/dist/ui/vim-input.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ interface VimInputProps {
|
|
|
14
14
|
focus?: boolean;
|
|
15
15
|
showMode?: boolean;
|
|
16
16
|
onModeChange?: (mode: VimMode) => void;
|
|
17
|
+
/** Probe the clipboard for an image and return the input-block to splice in
|
|
18
|
+
* (or null if there's no image). Wired to the same path as PromptTextInput's
|
|
19
|
+
* Ctrl+V fallback so vim-mode users on terminals that don't emit a
|
|
20
|
+
* bracketed-paste event for images can still paste. */
|
|
21
|
+
onClipboardImage?: () => Promise<string | null>;
|
|
17
22
|
}
|
|
18
|
-
export default function VimInput({ value, onChange, onSubmit, placeholder, focus, showMode, onModeChange, }: VimInputProps): import("react/jsx-runtime").JSX.Element;
|
|
23
|
+
export default function VimInput({ value, onChange, onSubmit, placeholder, focus, showMode, onModeChange, onClipboardImage, }: VimInputProps): import("react/jsx-runtime").JSX.Element;
|
|
19
24
|
export {};
|
package/dist/ui/vim-input.js
CHANGED
|
@@ -56,13 +56,18 @@ function endWord(text, pos) {
|
|
|
56
56
|
i++;
|
|
57
57
|
return Math.min(i, text.length - 1);
|
|
58
58
|
}
|
|
59
|
-
export default function VimInput({ value, onChange, onSubmit, placeholder = '', focus = true, showMode = true, onModeChange, }) {
|
|
59
|
+
export default function VimInput({ value, onChange, onSubmit, placeholder = '', focus = true, showMode = true, onModeChange, onClipboardImage, }) {
|
|
60
60
|
const [mode, setMode] = useState('insert');
|
|
61
61
|
const [cursor, setCursor] = useState(value.length);
|
|
62
62
|
const [cmdBuf, setCmdBuf] = useState(''); // accumulated command buffer (for counts + operators)
|
|
63
63
|
const [yankBuf, setYankBuf] = useState(''); // internal clipboard
|
|
64
64
|
const [undoStack, setUndoStack] = useState([]); // simple undo
|
|
65
65
|
const lastValueRef = useRef(value);
|
|
66
|
+
// Mirror the latest value prop every render so the async Ctrl+V clipboard
|
|
67
|
+
// insert (which resolves after the keypress) never splices into a stale
|
|
68
|
+
// string when the parent swaps `value` mid-probe — e.g. a submit clears the
|
|
69
|
+
// input, or another paste path writes first.
|
|
70
|
+
lastValueRef.current = value;
|
|
66
71
|
// Keep cursor in bounds when value changes externally
|
|
67
72
|
const clampedCursor = Math.min(cursor, mode === 'normal' ? Math.max(0, value.length - 1) : value.length);
|
|
68
73
|
const switchMode = useCallback((newMode) => {
|
|
@@ -155,6 +160,24 @@ export default function VimInput({ value, onChange, onSubmit, placeholder = '',
|
|
|
155
160
|
updateValue(value.slice(0, clampedCursor), clampedCursor);
|
|
156
161
|
return;
|
|
157
162
|
}
|
|
163
|
+
// Ctrl+V: clipboard-image fallback for terminals that don't emit a
|
|
164
|
+
// bracketed-paste event for image-only clipboards. Probe is async, so the
|
|
165
|
+
// handler returns now and updateValue happens when it resolves; capture
|
|
166
|
+
// the cursor offset so the block lands where the user pasted.
|
|
167
|
+
if (key.ctrl && input === 'v') {
|
|
168
|
+
if (onClipboardImage) {
|
|
169
|
+
const at = clampedCursor;
|
|
170
|
+
saveUndo();
|
|
171
|
+
onClipboardImage().then((injected) => {
|
|
172
|
+
if (!injected)
|
|
173
|
+
return;
|
|
174
|
+
const cur = lastValueRef.current;
|
|
175
|
+
const pos = Math.min(at, cur.length);
|
|
176
|
+
updateValue(cur.slice(0, pos) + injected + cur.slice(pos), pos + injected.length);
|
|
177
|
+
}).catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
158
181
|
// Skip control chars and tab
|
|
159
182
|
if (key.ctrl || key.meta || key.tab)
|
|
160
183
|
return;
|
package/package.json
CHANGED