@blockrun/franklin 3.15.79 → 3.15.82

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.
@@ -21,6 +21,22 @@ export declare function looksLikeGatewayErrorAsText(parts: ContentPart[]): {
21
21
  match: boolean;
22
22
  message: string;
23
23
  };
24
+ /**
25
+ * Detect a "stalled at intent" assistant turn: model emitted text-of-intent
26
+ * (e.g. "Let me check Node.js…", "I'll start by running npm install") but
27
+ * never bound a tool_use block. Coder-tuned models (qwen3-coder-*) and
28
+ * NIM-hosted Llama-4-Maverick frequently end_turn after declaring an action,
29
+ * stranding the agent loop with no progress.
30
+ *
31
+ * Returns true when the turn looks like a stall — caller should switch to a
32
+ * tool-use-strong model and retry the same prompt instead of treating the
33
+ * declared-but-unexecuted intent as the model's final answer.
34
+ *
35
+ * Conservative by design: only fires when the *tail* of the text shows
36
+ * action-intent + the message is long enough to look like a real plan, so
37
+ * legitimate short answers ("yes", "looks good") never get re-invoked.
38
+ */
39
+ export declare function looksLikeStalledIntent(text: string): boolean;
24
40
  /**
25
41
  * Walk a Dialogue and replace large `image.source.data` (base64) blocks
26
42
  * inside `tool_result.content` arrays with a tiny placeholder. The
@@ -318,6 +318,38 @@ function isToolRelevantToPrompt(toolName, promptLower) {
318
318
  // General-purpose / file / shell tools — always relevant.
319
319
  return true;
320
320
  }
321
+ /**
322
+ * Detect a "stalled at intent" assistant turn: model emitted text-of-intent
323
+ * (e.g. "Let me check Node.js…", "I'll start by running npm install") but
324
+ * never bound a tool_use block. Coder-tuned models (qwen3-coder-*) and
325
+ * NIM-hosted Llama-4-Maverick frequently end_turn after declaring an action,
326
+ * stranding the agent loop with no progress.
327
+ *
328
+ * Returns true when the turn looks like a stall — caller should switch to a
329
+ * tool-use-strong model and retry the same prompt instead of treating the
330
+ * declared-but-unexecuted intent as the model's final answer.
331
+ *
332
+ * Conservative by design: only fires when the *tail* of the text shows
333
+ * action-intent + the message is long enough to look like a real plan, so
334
+ * legitimate short answers ("yes", "looks good") never get re-invoked.
335
+ */
336
+ export function looksLikeStalledIntent(text) {
337
+ if (!text)
338
+ return false;
339
+ const trimmed = text.trim();
340
+ if (trimmed.length < 24)
341
+ return false;
342
+ // Look at the last ~400 chars only — intent-to-act lives near the end.
343
+ const tail = trimmed.slice(-400).toLowerCase();
344
+ // Strong "I'm about to do something" markers near the tail.
345
+ const englishIntent = /\b(let me|let's|i'?ll|i will|i need to|first[,\s]+(?:i|let)|now let'?s|now i'?ll|next[,\s]+i'?ll)\b[\s\S]{0,80}\b(check|verify|run|test|inspect|look|examine|confirm|see|try|install|build|create|start|begin)\b/;
346
+ const verifyMarkers = /\b(let'?s verify|let me check|let me run|let me inspect|let me test|let me look|let me see|let me try|let me start|i'?m going to|i'?ll start by|i'?ll first|i'?ll now)\b/;
347
+ if (englishIntent.test(tail))
348
+ return true;
349
+ if (verifyMarkers.test(tail))
350
+ return true;
351
+ return false;
352
+ }
321
353
  /**
322
354
  * Calculate backoff delay with jitter to avoid thundering herd.
323
355
  * Base: exponential (2^attempt * 1000ms), jitter: ±25%.
@@ -510,6 +542,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
510
542
  // Plan-then-execute: session-level disable flag lives on config (set by /noplan command)
511
543
  // Session persistence — reuse existing session ID when resuming, else create new
512
544
  const sessionId = config.resumeSessionId || createSessionId();
545
+ config.onSessionStart?.(sessionId);
513
546
  let turnCount = 0;
514
547
  // Resume: hydrate history from the saved JSONL transcript.
515
548
  // Sanitize to drop any orphaned tool_use / tool_result pairs from a crash.
@@ -1228,6 +1261,45 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1228
1261
  onEvent({ kind: 'turn_done', reason: 'no_progress' });
1229
1262
  break;
1230
1263
  }
1264
+ // ── Stalled-intent recovery ──
1265
+ // The model emitted text declaring an action ("Let me check Node.js…")
1266
+ // but never bound a tool_use block, so the agent loop has nothing to
1267
+ // execute. Verified 2026-05-06 in a Franklin session on
1268
+ // nvidia/qwen3-coder-480b: assistant said "First, I need to check if
1269
+ // Node.js and npm are available" then end_turn'd with no Bash call.
1270
+ // Coder-tuned models routinely treat declaring intent as completing
1271
+ // their turn. Same fix as empty-response: switch to a tool-use-strong
1272
+ // model and retry the same prompt — re-prompting the same model is
1273
+ // deterministic waste because the stall is a model-behavior trait.
1274
+ if (!hasTools && hasText) {
1275
+ const tailText = responseParts
1276
+ .filter(p => p.type === 'text')
1277
+ .map(p => p.text ?? '')
1278
+ .join('\n');
1279
+ if (looksLikeStalledIntent(tailText)) {
1280
+ // Tool-use-strong fallbacks. Ordered cheap → premium so a free
1281
+ // tier still gets a Kimi/Haiku attempt before paying for GPT-5.
1282
+ // Excludes nvidia/* and *-coder-* — they're the source population.
1283
+ const TOOL_USE_FALLBACK_MODELS = [
1284
+ 'anthropic/claude-haiku-4.5',
1285
+ 'moonshot/kimi-k2',
1286
+ 'openai/gpt-5',
1287
+ 'anthropic/claude-sonnet-4.6',
1288
+ ];
1289
+ const nextModel = TOOL_USE_FALLBACK_MODELS.find(m => m !== config.model && !turnFailedModels.has(m));
1290
+ if (nextModel && recoveryAttempts < 2) {
1291
+ recoveryAttempts++;
1292
+ turnFailedModels.add(config.model);
1293
+ const oldModel = config.model;
1294
+ config.model = nextModel;
1295
+ config.onModelChange?.(nextModel, 'system');
1296
+ const switchLine = formatModelSwitch(oldModel, resolvedModel, 'declared intent without tool_use', nextModel);
1297
+ logger.warn(`[franklin] ${switchLine}`);
1298
+ onEvent({ kind: 'text_delta', text: `\n*${switchLine}*\n` });
1299
+ continue;
1300
+ }
1301
+ }
1302
+ }
1231
1303
  }
1232
1304
  catch (err) {
1233
1305
  // ── User abort (Esc key) ──
@@ -169,6 +169,8 @@ export interface AgentConfig {
169
169
  baseModel?: string;
170
170
  /** Resume an existing session by ID — loads prior history and keeps appending to the same JSONL */
171
171
  resumeSessionId?: string;
172
+ /** Notify callers of the concrete session ID once created/resolved. */
173
+ onSessionStart?: (sessionId: string) => void;
172
174
  /**
173
175
  * Optional channel tag persisted to SessionMeta. Lets non-CLI drivers
174
176
  * (Telegram bot, Discord bot, future ingresses) find their own sessions
@@ -434,6 +434,8 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
434
434
  agentConfig.permissionPromptFn = (toolName, description) => ui.requestPermission(toolName, description);
435
435
  agentConfig.onAskUser = (question, options) => ui.requestAskUser(question, options);
436
436
  agentConfig.onModelChange = (model) => ui.updateModel(model);
437
+ let activeSessionId = agentConfig.resumeSessionId;
438
+ agentConfig.onSessionStart = (sessionId) => { activeSessionId = sessionId; };
437
439
  // Wire up background balance fetch to UI
438
440
  onBalanceReady?.((bal) => ui.updateBalance(bal));
439
441
  // Refresh balance after each completed turn so the display stays current
@@ -505,6 +507,21 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
505
507
  }
506
508
  }
507
509
  catch { /* stats unavailable */ }
510
+ let savedSessionId;
511
+ if (activeSessionId) {
512
+ try {
513
+ const { loadSessionMeta } = await import('../session/storage.js');
514
+ const meta = loadSessionMeta(activeSessionId);
515
+ if ((meta?.messageCount ?? 0) > 0)
516
+ savedSessionId = activeSessionId;
517
+ }
518
+ catch { /* session hint is best-effort */ }
519
+ }
520
+ if (savedSessionId) {
521
+ console.log(chalk.dim(`\n Session: ${savedSessionId}`));
522
+ console.log(chalk.dim(` Resume: franklin --resume ${savedSessionId}`));
523
+ console.log(chalk.dim(' Latest: franklin --continue'));
524
+ }
508
525
  console.log(chalk.dim('\nGoodbye.\n'));
509
526
  }
510
527
  // ─── Basic readline UI (piped input) ───────────────────────────────────────
@@ -19,7 +19,10 @@ export function statsCommand(options) {
19
19
  // the user sees the wire-level total alongside Franklin's recorded one.
20
20
  // The gap between the two = recording instrumentation that's still
21
21
  // missing from helper paths (analyzeTurn, compaction, evaluator, etc.).
22
- const sdkLedger = summarizeSdkSettlements();
22
+ const statsWindowStartMs = stats.resetAt ?? stats.firstRequest;
23
+ const sdkLedger = summarizeSdkSettlements(typeof statsWindowStartMs === 'number'
24
+ ? { sinceMs: statsWindowStartMs }
25
+ : undefined);
23
26
  const recordedTotal = stats.totalCostUsd;
24
27
  const sdkTotal = sdkLedger.totalUsd;
25
28
  const gap = sdkTotal - recordedTotal;
@@ -51,6 +54,7 @@ export function statsCommand(options) {
51
54
  byEndpoint: sdkLedger.byEndpoint.slice(0, 10),
52
55
  firstTs: sdkLedger.firstTs,
53
56
  lastTs: sdkLedger.lastTs,
57
+ sinceMs: statsWindowStartMs ?? null,
54
58
  },
55
59
  reconciliation: {
56
60
  recordedUsd: recordedTotal,
@@ -58,6 +62,7 @@ export function statsCommand(options) {
58
62
  gapUsd: gap,
59
63
  gapPct,
60
64
  significantGap,
65
+ windowStartMs: statsWindowStartMs ?? null,
61
66
  },
62
67
  }, null, 2));
63
68
  return;
@@ -65,7 +70,7 @@ export function statsCommand(options) {
65
70
  // Pretty output
66
71
  console.log(chalk.bold('\n📊 Franklin Usage Statistics\n'));
67
72
  console.log('─'.repeat(55));
68
- if (stats.totalRequests === 0) {
73
+ if (stats.totalRequests === 0 && sdkTotal === 0) {
69
74
  console.log(chalk.gray('\n No requests recorded yet. Start using franklin!\n'));
70
75
  console.log('─'.repeat(55) + '\n');
71
76
  return;
@@ -30,6 +30,7 @@ export interface Stats {
30
30
  totalFallbacks: number;
31
31
  byModel: Record<string, ModelStats>;
32
32
  history: UsageRecord[];
33
+ resetAt?: number;
33
34
  firstRequest?: number;
34
35
  lastRequest?: number;
35
36
  }
@@ -123,6 +123,7 @@ export function clearStats() {
123
123
  /* ignore */
124
124
  }
125
125
  }
126
+ saveStats({ ...EMPTY_STATS, resetAt: Date.now() });
126
127
  }
127
128
  // ─── In-memory stats cache with debounced write ─────────────────────────
128
129
  // Prevents concurrent load→modify→save from losing data in proxy mode
package/dist/ui/app.js CHANGED
@@ -16,8 +16,204 @@ import { formatTokens, shortModelName } from '../stats/format.js';
16
16
  import { mouse, forceDisableMouseTracking } from './mouse.js';
17
17
  import { resolveAskUserAnswer } from './ask-user-answer.js';
18
18
  // ─── Full-width input box ──────────────────────────────────────────────────
19
+ const BRACKETED_PASTE_START = '[200~';
20
+ const BRACKETED_PASTE_END = '[201~';
21
+ const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
22
+ const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
23
+ const USER_PROMPT_COLOR = '#FFD700';
24
+ const PASTE_BLOCK_START = '\uE000PASTE:';
25
+ const PASTE_BLOCK_END = ':PASTE\uE001';
19
26
  const DISABLE_AUTO_WRAP = '\x1b[?7l';
20
27
  const ENABLE_AUTO_WRAP = '\x1b[?7h';
28
+ function stripPasteMarkers(input) {
29
+ return input
30
+ .replaceAll(BRACKETED_PASTE_START, '')
31
+ .replaceAll(BRACKETED_PASTE_END, '');
32
+ }
33
+ function normalizeInputNewlines(input) {
34
+ return input.replace(/\r\n|\r|\n/g, '\n').replace(/\x1b/g, '');
35
+ }
36
+ function encodePasteBlock(content) {
37
+ return `${PASTE_BLOCK_START}${Buffer.from(content, 'utf8').toString('base64')}${PASTE_BLOCK_END}`;
38
+ }
39
+ function decodePasteBlock(token) {
40
+ if (!token.startsWith(PASTE_BLOCK_START) || !token.endsWith(PASTE_BLOCK_END))
41
+ return token;
42
+ const payload = token.slice(PASTE_BLOCK_START.length, -PASTE_BLOCK_END.length);
43
+ try {
44
+ return Buffer.from(payload, 'base64').toString('utf8');
45
+ }
46
+ catch {
47
+ return token;
48
+ }
49
+ }
50
+ function findPasteBlocks(value) {
51
+ const blocks = [];
52
+ let searchFrom = 0;
53
+ while (searchFrom < value.length) {
54
+ const start = value.indexOf(PASTE_BLOCK_START, searchFrom);
55
+ if (start < 0)
56
+ break;
57
+ const endMarker = value.indexOf(PASTE_BLOCK_END, start + PASTE_BLOCK_START.length);
58
+ if (endMarker < 0)
59
+ break;
60
+ const end = endMarker + PASTE_BLOCK_END.length;
61
+ blocks.push({ start, end, content: decodePasteBlock(value.slice(start, end)) });
62
+ searchFrom = end;
63
+ }
64
+ return blocks;
65
+ }
66
+ function decodePromptValue(value) {
67
+ let decoded = '';
68
+ let cursor = 0;
69
+ for (const block of findPasteBlocks(value)) {
70
+ decoded += value.slice(cursor, block.start) + block.content;
71
+ cursor = block.end;
72
+ }
73
+ return decoded + value.slice(cursor);
74
+ }
75
+ function pasteSummary(content) {
76
+ const lines = content.length === 0 ? 0 : content.split('\n').length;
77
+ const lineLabel = lines > 1 ? `~${lines} lines` : '~1 line';
78
+ return `[Pasted ${lineLabel}]`;
79
+ }
80
+ function renderInputValue(value, cursorOffset, focused) {
81
+ const blocks = findPasteBlocks(value);
82
+ if (blocks.length > 0) {
83
+ let rendered = '';
84
+ let cursor = 0;
85
+ for (const block of blocks) {
86
+ rendered += renderPlainInputSegment(value.slice(cursor, block.start), cursorOffset - cursor, focused && cursorOffset >= cursor && cursorOffset <= block.start);
87
+ if (focused && cursorOffset === block.start)
88
+ rendered += chalk.inverse(' ');
89
+ rendered += chalk.hex(USER_PROMPT_COLOR).bold(pasteSummary(block.content));
90
+ if (focused && cursorOffset === block.end)
91
+ rendered += chalk.inverse(' ');
92
+ cursor = block.end;
93
+ }
94
+ rendered += renderPlainInputSegment(value.slice(cursor), cursorOffset - cursor, focused && cursorOffset >= cursor);
95
+ return rendered || (focused ? chalk.inverse(' ') : '');
96
+ }
97
+ return renderPlainInputSegment(value, cursorOffset, focused);
98
+ }
99
+ function renderPlainInputSegment(value, cursorOffset, focused) {
100
+ const displayValue = value.replace(/\r\n|\r|\n/g, ' ');
101
+ if (!focused)
102
+ return displayValue;
103
+ const safeCursor = Math.max(0, Math.min(cursorOffset, displayValue.length));
104
+ if (displayValue.length === 0)
105
+ return chalk.inverse(' ');
106
+ const before = displayValue.slice(0, safeCursor);
107
+ const current = displayValue[safeCursor] ?? ' ';
108
+ const after = displayValue.slice(safeCursor + (safeCursor < displayValue.length ? 1 : 0));
109
+ return before + chalk.inverse(current) + after;
110
+ }
111
+ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus = true }) {
112
+ const [cursorOffset, setCursorOffset] = useState(value.length);
113
+ const valueRef = useRef(value);
114
+ const cursorOffsetRef = useRef(value.length);
115
+ const pasteActiveRef = useRef(false);
116
+ const pasteBufferRef = useRef('');
117
+ useEffect(() => {
118
+ valueRef.current = value;
119
+ setCursorOffset((offset) => {
120
+ const nextOffset = Math.min(offset, value.length);
121
+ cursorOffsetRef.current = nextOffset;
122
+ return nextOffset;
123
+ });
124
+ }, [value]);
125
+ const updateValue = useCallback((nextValue, nextCursorOffset) => {
126
+ valueRef.current = nextValue;
127
+ cursorOffsetRef.current = Math.max(0, Math.min(nextCursorOffset, nextValue.length));
128
+ onChange(nextValue);
129
+ setCursorOffset(cursorOffsetRef.current);
130
+ }, [onChange]);
131
+ useInput((input, key) => {
132
+ if (!focus)
133
+ return;
134
+ const currentValue = valueRef.current;
135
+ const currentCursorOffset = cursorOffsetRef.current;
136
+ const pasteBlockBeforeCursor = findPasteBlocks(currentValue).find((block) => block.end === currentCursorOffset);
137
+ const pasteBlockAfterCursor = findPasteBlocks(currentValue).find((block) => block.start === currentCursorOffset);
138
+ const hasPasteStart = input.includes(BRACKETED_PASTE_START);
139
+ const hasPasteEnd = input.includes(BRACKETED_PASTE_END);
140
+ const isPasting = pasteActiveRef.current || hasPasteStart;
141
+ if (hasPasteStart && !pasteActiveRef.current) {
142
+ pasteActiveRef.current = true;
143
+ pasteBufferRef.current = '';
144
+ }
145
+ if (key.return && !isPasting) {
146
+ onSubmit(decodePromptValue(currentValue));
147
+ return;
148
+ }
149
+ if (key.home || (key.ctrl && input === 'a')) {
150
+ cursorOffsetRef.current = 0;
151
+ setCursorOffset(0);
152
+ return;
153
+ }
154
+ if (key.end || (key.ctrl && input === 'e')) {
155
+ cursorOffsetRef.current = currentValue.length;
156
+ setCursorOffset(currentValue.length);
157
+ return;
158
+ }
159
+ if (key.leftArrow) {
160
+ const previousBlock = findPasteBlocks(currentValue).find((block) => block.end === currentCursorOffset);
161
+ const nextOffset = previousBlock ? previousBlock.start : Math.max(0, currentCursorOffset - 1);
162
+ cursorOffsetRef.current = nextOffset;
163
+ setCursorOffset(nextOffset);
164
+ return;
165
+ }
166
+ if (key.rightArrow) {
167
+ const nextBlock = findPasteBlocks(currentValue).find((block) => block.start === currentCursorOffset);
168
+ const nextOffset = nextBlock ? nextBlock.end : Math.min(currentValue.length, currentCursorOffset + 1);
169
+ cursorOffsetRef.current = nextOffset;
170
+ setCursorOffset(nextOffset);
171
+ return;
172
+ }
173
+ if (key.backspace || key.delete) {
174
+ if (key.backspace && pasteBlockBeforeCursor) {
175
+ updateValue(currentValue.slice(0, pasteBlockBeforeCursor.start) + currentValue.slice(pasteBlockBeforeCursor.end), pasteBlockBeforeCursor.start);
176
+ return;
177
+ }
178
+ if (key.delete && pasteBlockAfterCursor) {
179
+ updateValue(currentValue.slice(0, pasteBlockAfterCursor.start) + currentValue.slice(pasteBlockAfterCursor.end), pasteBlockAfterCursor.start);
180
+ return;
181
+ }
182
+ if (currentCursorOffset > 0) {
183
+ updateValue(currentValue.slice(0, currentCursorOffset - 1) + currentValue.slice(currentCursorOffset), currentCursorOffset - 1);
184
+ }
185
+ return;
186
+ }
187
+ if (key.upArrow || key.downArrow || key.tab || key.ctrl || key.meta)
188
+ return;
189
+ let text = normalizeInputNewlines(stripPasteMarkers(input));
190
+ if (key.return && isPasting)
191
+ text = '\n';
192
+ if (isPasting) {
193
+ pasteBufferRef.current += text;
194
+ if (!hasPasteEnd)
195
+ return;
196
+ text = encodePasteBlock(pasteBufferRef.current);
197
+ pasteBufferRef.current = '';
198
+ pasteActiveRef.current = false;
199
+ }
200
+ if (!text) {
201
+ if (hasPasteEnd)
202
+ pasteActiveRef.current = false;
203
+ return;
204
+ }
205
+ updateValue(currentValue.slice(0, currentCursorOffset) + text + currentValue.slice(currentCursorOffset), currentCursorOffset + text.length);
206
+ if (hasPasteEnd)
207
+ pasteActiveRef.current = false;
208
+ }, { isActive: focus });
209
+ const rendered = value.length > 0
210
+ ? renderInputValue(value, cursorOffset, focus)
211
+ : (focus && placeholder ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.grey(placeholder));
212
+ return _jsx(Text, { children: rendered });
213
+ }
214
+ function formatUserPromptForDisplay(value) {
215
+ return `❯ ${decodePromptValue(value)}`;
216
+ }
21
217
  function disableTerminalAutoWrap() {
22
218
  if (!process.stdout.isTTY)
23
219
  return undefined;
@@ -35,6 +231,23 @@ function disableTerminalAutoWrap() {
35
231
  restore();
36
232
  };
37
233
  }
234
+ function enableBracketedPaste() {
235
+ if (!process.stdout.isTTY)
236
+ return undefined;
237
+ let restored = false;
238
+ const restore = () => {
239
+ if (restored || !process.stdout.writable)
240
+ return;
241
+ restored = true;
242
+ process.stdout.write(DISABLE_BRACKETED_PASTE);
243
+ };
244
+ process.stdout.write(ENABLE_BRACKETED_PASTE);
245
+ process.once('exit', restore);
246
+ return () => {
247
+ process.off('exit', restore);
248
+ restore();
249
+ };
250
+ }
38
251
  // Subscribe to terminal resize so React re-renders with fresh dimensions.
39
252
  // Without this, useStdout() returns a stable ref and children that read
40
253
  // stdout.columns on each render still need React to re-execute them — which
@@ -85,7 +298,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail
85
298
  const leadingGlyph = (awaitingApproval || awaitingAnswer)
86
299
  ? _jsx(Text, { color: "yellow", bold: true, children: "\u26A0 " })
87
300
  : (showSpinner ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null);
88
- 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(TextInput, { 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 ", (() => {
301
+ 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 ", (() => {
89
302
  // Color the balance by funding state. Real session 2026-05-04
90
303
  // had a user staring at "$0.08 USDC" in dim text wondering
91
304
  // whether it meant "out of money" or "wrong chain". Make
@@ -262,6 +475,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
262
475
  const turnSavingsRef = useRef(undefined);
263
476
  const turnCtxPctRef = useRef(undefined);
264
477
  const queuedInputsRef = useRef([]);
478
+ const lastCtrlCRef = useRef(0);
265
479
  // Keep refs in sync so memoized event handlers can read current values
266
480
  streamTextRef.current = streamText;
267
481
  turnTokensRef.current = turnTokens;
@@ -280,6 +494,23 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
280
494
  setTimeout(() => setStatusMsg(''), durationMs);
281
495
  }
282
496
  }, []);
497
+ const requestExit = useCallback((abortTurn = false) => {
498
+ if (abortTurn)
499
+ onAbort();
500
+ onExit();
501
+ exit();
502
+ }, [onAbort, onExit, exit]);
503
+ useInput((ch, key) => {
504
+ if (!(key.ctrl && ch === 'c'))
505
+ return;
506
+ const now = Date.now();
507
+ if (now - lastCtrlCRef.current < 2000) {
508
+ requestExit(true);
509
+ return;
510
+ }
511
+ lastCtrlCRef.current = now;
512
+ showStatus('Press Ctrl+C again to exit', 'warning', 2000);
513
+ });
283
514
  const commitResponse = useCallback((text, tokens = turnTokensRef.current, cost = turnCostRef.current) => {
284
515
  if (!text.trim())
285
516
  return;
@@ -334,7 +565,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
334
565
  }, { isActive: !!permissionRequest });
335
566
  // Key handler for picker + esc + abort
336
567
  const isPickerOrEsc = mode === 'model-picker' || (mode === 'input' && ready && !input) || !ready;
337
- useInput((ch, key) => {
568
+ useInput((_ch, key) => {
338
569
  // Escape during generation → abort current turn (skip if permission dialog open)
339
570
  if (key.escape && !ready && !permissionRequest) {
340
571
  onAbort();
@@ -349,8 +580,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
349
580
  if (key.escape && mode === 'input' && ready && !input) {
350
581
  if (vimEnabled && currentVimMode === 'insert')
351
582
  return; // Let VimInput handle Esc → normal
352
- onExit();
353
- exit();
583
+ requestExit(false);
354
584
  return;
355
585
  }
356
586
  // Arrow key navigation for model picker
@@ -416,9 +646,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
416
646
  const isExit = lower === 'exit' || lower === 'quit' || lower === 'q' ||
417
647
  lower === '/exit' || lower === '/quit';
418
648
  if (isExit) {
419
- onAbort();
420
- onExit();
421
- exit();
649
+ requestExit(true);
422
650
  return;
423
651
  }
424
652
  // If agent is busy, queue the message — it will be auto-submitted when the turn finishes
@@ -522,9 +750,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
522
750
  // Show user message in scrollback so the conversation is readable
523
751
  setCommittedResponses(rs => [...rs, {
524
752
  key: `user-${Date.now()}`,
525
- // Gold matches the top of the Franklin banner gradient (#FFD700).
526
- // Brand-consistent, readable on dark terminals, evokes $100-bill identity.
527
- text: chalk.hex('#FFD700').bold('❯ ') + chalk.hex('#FFD700').bold(trimmed),
753
+ text: formatUserPromptForDisplay(trimmed),
528
754
  tokens: { input: 0, output: 0, calls: 0 },
529
755
  cost: 0,
530
756
  }]);
@@ -556,7 +782,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
556
782
  turnSavingsRef.current = undefined;
557
783
  turnCtxPctRef.current = undefined;
558
784
  onSubmit(trimmed);
559
- }, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]);
785
+ }, [ready, currentModel, totalCost, onSubmit, onModelChange, requestExit, lastPrompt, inputHistory, showStatus]);
560
786
  // Mouse support — OFF by default because Node stdin is shared: mouse escape
561
787
  // sequences leak into Ink's input handler as typed text. Opt in with
562
788
  // FRANKLIN_MOUSE=1 if you want click-to-expand-tool + drag-to-copy.
@@ -820,7 +1046,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
820
1046
  const isUserMsg = r.key.startsWith('user-');
821
1047
  return (_jsxs(Box, { flexDirection: "column", children: [!isUserMsg && (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(60) }) })), isUserMsg && (_jsx(Box, { marginTop: 1 })), !isUserMsg && r.thinkMs !== undefined && r.thinkMs >= 500 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "magenta", dimColor: true, children: ["\u273B Thought for ", (r.thinkMs / 1000).toFixed(1), "s", r.thinkChars && r.thinkChars > 20
822
1048
  ? ` · ~${Math.round(r.thinkChars / 4)} tokens`
823
- : ''] }) })), _jsx(Box, { paddingLeft: isUserMsg ? 0 : 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [r.tier
1049
+ : ''] }) })), _jsx(Box, { paddingLeft: isUserMsg ? 0 : 2, children: isUserMsg ? (_jsx(Text, { wrap: "wrap", color: USER_PROMPT_COLOR, bold: true, children: r.text })) : (_jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) })) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [r.tier
824
1050
  ? _jsxs(Text, { color: "cyan", children: ["[", r.tier, "] "] })
825
1051
  : (r.model ? _jsx(Text, { dimColor: true, children: "[direct] " }) : null), r.model ? shortModelName(r.model) : '', r.model ? ' · ' : '', r.tokens.calls > 0 && r.tokens.input === 0
826
1052
  ? `${r.tokens.calls} calls`
@@ -900,7 +1126,19 @@ export function launchInkUI(opts) {
900
1126
  let exiting = false;
901
1127
  let abortCallback = null;
902
1128
  const restoreTerminalAutoWrap = disableTerminalAutoWrap();
903
- const instance = render(_jsx(RunCodeApp, { initialModel: opts.model, workDir: opts.workDir, walletAddress: opts.walletAddress || 'not set — run: franklin setup', walletBalance: opts.walletBalance || 'unknown', chain: opts.chain || 'base', startWithPicker: opts.showPicker, onSubmit: (value) => {
1129
+ const restoreBracketedPaste = enableBracketedPaste();
1130
+ let cleanedUp = false;
1131
+ let instance;
1132
+ const cleanup = () => {
1133
+ if (cleanedUp)
1134
+ return;
1135
+ cleanedUp = true;
1136
+ mouse.disable();
1137
+ restoreBracketedPaste?.();
1138
+ restoreTerminalAutoWrap?.();
1139
+ instance?.unmount();
1140
+ };
1141
+ instance = render(_jsx(RunCodeApp, { initialModel: opts.model, workDir: opts.workDir, walletAddress: opts.walletAddress || 'not set — run: franklin setup', walletBalance: opts.walletBalance || 'unknown', chain: opts.chain || 'base', startWithPicker: opts.showPicker, onSubmit: (value) => {
904
1142
  if (resolveInput) {
905
1143
  resolveInput(value);
906
1144
  resolveInput = null;
@@ -915,7 +1153,8 @@ export function launchInkUI(opts) {
915
1153
  resolveInput(null);
916
1154
  resolveInput = null;
917
1155
  }
918
- } }));
1156
+ cleanup();
1157
+ } }), { exitOnCtrlC: false });
919
1158
  return {
920
1159
  handleEvent: (event) => {
921
1160
  const ui = globalThis.__franklin_ui;
@@ -945,11 +1184,7 @@ export function launchInkUI(opts) {
945
1184
  return new Promise((resolve) => { resolveInput = resolve; });
946
1185
  },
947
1186
  onAbort: (cb) => { abortCallback = cb; },
948
- cleanup: () => {
949
- mouse.disable();
950
- instance.unmount();
951
- restoreTerminalAutoWrap?.();
952
- },
1187
+ cleanup,
953
1188
  requestPermission: (toolName, description) => {
954
1189
  const ui = globalThis.__franklin_ui;
955
1190
  return ui?.requestPermission(toolName, description) ?? Promise.resolve('no');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.79",
3
+ "version": "3.15.82",
4
4
  "description": "Franklin — 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": {