@blockrun/franklin 3.15.79 → 3.15.83
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/loop.d.ts +16 -0
- package/dist/agent/loop.js +80 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/commands/start.js +17 -0
- package/dist/commands/stats.js +7 -2
- package/dist/stats/tracker.d.ts +1 -0
- package/dist/stats/tracker.js +1 -0
- package/dist/ui/app.js +254 -19
- package/package.json +1 -1
package/dist/agent/loop.d.ts
CHANGED
|
@@ -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
|
package/dist/agent/loop.js
CHANGED
|
@@ -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) ──
|
|
@@ -1614,7 +1686,14 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1614
1686
|
const gatewayErr = looksLikeGatewayErrorAsText(responseParts);
|
|
1615
1687
|
if (gatewayErr.match) {
|
|
1616
1688
|
logger.error(`[franklin] Gateway returned an error text in lieu of an answer (${resolvedModel}): ${gatewayErr.message}`);
|
|
1617
|
-
|
|
1689
|
+
lastSessionActivity = Date.now();
|
|
1690
|
+
persistSessionMeta();
|
|
1691
|
+
onEvent({
|
|
1692
|
+
kind: 'turn_done',
|
|
1693
|
+
reason: 'error',
|
|
1694
|
+
error: gatewayErr.message,
|
|
1695
|
+
});
|
|
1696
|
+
break;
|
|
1618
1697
|
}
|
|
1619
1698
|
// Reset recovery counter on successful completion
|
|
1620
1699
|
recoveryAttempts = 0;
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/start.js
CHANGED
|
@@ -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) ───────────────────────────────────────
|
package/dist/commands/stats.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/stats/tracker.d.ts
CHANGED
package/dist/stats/tracker.js
CHANGED
|
@@ -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(
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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