@blockrun/franklin 3.3.0 → 3.3.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.
@@ -9,27 +9,30 @@ const MAX_BODY_BYTES = 256 * 1024; // 256KB
9
9
  const CACHE_TTL_MS = 15 * 60 * 1000;
10
10
  const MAX_CACHE_ENTRIES = 50;
11
11
  const fetchCache = new Map();
12
- function getCached(url) {
13
- const entry = fetchCache.get(url);
12
+ function cacheKey(url, maxLength) {
13
+ return `${url}::${maxLength}`;
14
+ }
15
+ function getCached(key) {
16
+ const entry = fetchCache.get(key);
14
17
  if (!entry)
15
18
  return null;
16
19
  if (Date.now() > entry.expiresAt) {
17
- fetchCache.delete(url);
20
+ fetchCache.delete(key);
18
21
  return null;
19
22
  }
20
23
  return entry.output;
21
24
  }
22
- function setCached(url, output) {
25
+ function setCached(key, output) {
23
26
  // Evict oldest entry if at capacity
24
27
  if (fetchCache.size >= MAX_CACHE_ENTRIES) {
25
28
  const firstKey = fetchCache.keys().next().value;
26
29
  if (firstKey)
27
30
  fetchCache.delete(firstKey);
28
31
  }
29
- fetchCache.set(url, { output, expiresAt: Date.now() + CACHE_TTL_MS });
32
+ fetchCache.set(key, { output, expiresAt: Date.now() + CACHE_TTL_MS });
30
33
  }
31
34
  // ─── Execute ────────────────────────────────────────────────────────────────
32
- async function execute(input, _ctx) {
35
+ async function execute(input, ctx) {
33
36
  const { url, max_length } = input;
34
37
  if (!url) {
35
38
  return { output: 'Error: url is required', isError: true };
@@ -45,13 +48,17 @@ async function execute(input, _ctx) {
45
48
  if (!['http:', 'https:'].includes(parsed.protocol)) {
46
49
  return { output: `Error: only http/https URLs are supported`, isError: true };
47
50
  }
51
+ const maxLen = Math.min(max_length ?? MAX_BODY_BYTES, MAX_BODY_BYTES);
52
+ const key = cacheKey(url, maxLen);
48
53
  // Check cache first
49
- const cached = getCached(url);
54
+ const cached = getCached(key);
50
55
  if (cached) {
51
56
  return { output: cached + '\n\n(cached)' };
52
57
  }
53
58
  const controller = new AbortController();
54
59
  const timeout = setTimeout(() => controller.abort(), 30_000);
60
+ const onAbort = () => controller.abort();
61
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
55
62
  try {
56
63
  const response = await fetch(url, {
57
64
  signal: controller.signal,
@@ -68,7 +75,6 @@ async function execute(input, _ctx) {
68
75
  };
69
76
  }
70
77
  const contentType = response.headers.get('content-type') || '';
71
- const maxLen = Math.min(max_length ?? MAX_BODY_BYTES, MAX_BODY_BYTES);
72
78
  // Read body with size limit
73
79
  const reader = response.body?.getReader();
74
80
  if (!reader) {
@@ -106,11 +112,14 @@ async function execute(input, _ctx) {
106
112
  output += '\n\n... (content truncated)';
107
113
  }
108
114
  // Cache successful responses
109
- setCached(url, output);
115
+ setCached(key, output);
110
116
  return { output };
111
117
  }
112
118
  catch (err) {
113
119
  const msg = err instanceof Error ? err.message : String(err);
120
+ if (ctx.abortSignal.aborted) {
121
+ return { output: `Error: request aborted for ${url}`, isError: true };
122
+ }
114
123
  if (msg.includes('abort')) {
115
124
  return { output: `Error: request timed out after 30s for ${url}`, isError: true };
116
125
  }
@@ -118,6 +127,7 @@ async function execute(input, _ctx) {
118
127
  }
119
128
  finally {
120
129
  clearTimeout(timeout);
130
+ ctx.abortSignal.removeEventListener('abort', onAbort);
121
131
  }
122
132
  }
123
133
  function stripHtml(html) {
@@ -4,6 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
+ import { partiallyReadFiles } from './read.js';
7
8
  function withTrailingSep(value) {
8
9
  return value.endsWith(path.sep) ? value : value + path.sep;
9
10
  }
@@ -84,6 +85,7 @@ async function execute(input, ctx) {
84
85
  fs.mkdirSync(parentDir, { recursive: true });
85
86
  const existed = fs.existsSync(resolved);
86
87
  fs.writeFileSync(resolved, content, 'utf-8');
88
+ partiallyReadFiles.delete(resolved);
87
89
  const lineCount = content.split('\n').length;
88
90
  const byteCount = Buffer.byteLength(content, 'utf-8');
89
91
  const sizeStr = byteCount >= 1024 ? `${(byteCount / 1024).toFixed(1)}KB` : `${byteCount}B`;
package/dist/ui/app.js CHANGED
@@ -3,21 +3,25 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  * RunCode ink-based terminal UI.
4
4
  * Real-time streaming, thinking animation, tool progress, slash commands.
5
5
  */
6
+ import chalk from 'chalk';
6
7
  import { useState, useEffect, useCallback, useRef } from 'react';
7
8
  import { render, Static, Box, Text, useApp, useInput, useStdout } from 'ink';
8
9
  import Spinner from 'ink-spinner';
9
10
  import TextInput from 'ink-text-input';
11
+ import { renderMarkdown } from './markdown.js';
10
12
  import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-picker.js';
11
13
  import { estimateCost } from '../pricing.js';
12
14
  // ─── Full-width input box ──────────────────────────────────────────────────
13
- function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, focused, busy }) {
15
+ function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy }) {
14
16
  const { stdout } = useStdout();
15
17
  const cols = stdout?.columns ?? 80;
16
18
  const innerWidth = Math.min(Math.max(30, cols - 4), cols - 2);
17
19
  const placeholder = busy
18
- ? (queued ? `⏎ queued: ${queued.slice(0, 40)}` : 'Working...')
20
+ ? (queued
21
+ ? `⏎ ${queuedCount ?? 1} queued: ${queued.slice(0, 40)}`
22
+ : 'Working...')
19
23
  : 'Type a message...';
20
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: '╭' + '─'.repeat(cols - 2) + '╮' }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "\u2502 " }), busy && !input ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsx(Box, { width: busy && !input ? innerWidth - 4 : innerWidth, children: _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false }) }), _jsxs(Text, { dimColor: true, children: [' '.repeat(Math.max(0, cols - innerWidth - 4)), "\u2502"] })] }), _jsx(Text, { dimColor: true, children: '╰' + '─'.repeat(cols - 2) + '╯' }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', model, " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', ' · esc to abort/quit'] }) })] }));
24
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: '╭' + '─'.repeat(cols - 2) + '╮' }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "\u2502 " }), busy && !input ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsx(Box, { width: busy && !input ? innerWidth - 4 : innerWidth, children: _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false }) }), _jsxs(Text, { dimColor: true, children: [' '.repeat(Math.max(0, cols - innerWidth - 4)), "\u2502"] })] }), _jsx(Text, { dimColor: true, children: '╰' + '─'.repeat(cols - 2) + '╯' }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', model, " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', (queuedCount ?? 0) > 0 ? _jsxs(Text, { color: "cyan", children: [" \u00B7 ", queuedCount, " queued"] }) : null, ' · esc to abort/quit'] }) })] }));
21
25
  }
22
26
  function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) {
23
27
  const { exit } = useApp();
@@ -37,6 +41,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
37
41
  const [mode, setMode] = useState(startWithPicker ? 'model-picker' : 'input');
38
42
  const [pickerIdx, setPickerIdx] = useState(0);
39
43
  const [statusMsg, setStatusMsg] = useState('');
44
+ const [statusTone, setStatusTone] = useState('success');
40
45
  const [turnTokens, setTurnTokens] = useState({ input: 0, output: 0, calls: 0 });
41
46
  const [totalCost, setTotalCost] = useState(0);
42
47
  const [showHelp, setShowHelp] = useState(false);
@@ -59,26 +64,50 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
59
64
  const [permissionRequest, setPermissionRequest] = useState(null);
60
65
  const [askUserRequest, setAskUserRequest] = useState(null);
61
66
  const [askUserInput, setAskUserInput] = useState('');
62
- // Message queued while agent is busy — auto-submitted when turn completes
63
- const [queuedInput, setQueuedInput] = useState('');
67
+ // Messages queued while agent is busy — auto-submitted FIFO when turns complete.
68
+ const [queuedInputs, setQueuedInputs] = useState([]);
64
69
  const turnDoneCallbackRef = useRef(null);
65
70
  // Refs to read current state values inside memoized event handlers (avoids stale closures)
66
71
  const streamTextRef = useRef('');
67
72
  const turnTokensRef = useRef({ input: 0, output: 0, calls: 0 });
68
73
  const totalCostRef = useRef(0);
69
74
  const turnCostRef = useRef(0); // per-turn cost (reset each turn)
70
- const queuedInputRef = useRef('');
75
+ const queuedInputsRef = useRef([]);
71
76
  // Keep refs in sync so memoized event handlers can read current values
72
77
  streamTextRef.current = streamText;
73
78
  turnTokensRef.current = turnTokens;
74
79
  totalCostRef.current = totalCost;
75
- queuedInputRef.current = queuedInput;
80
+ queuedInputsRef.current = queuedInputs;
76
81
  costAtLastFetchRef.current = costAtLastFetch;
77
82
  baseBalanceNumRef.current = baseBalanceNum;
78
83
  // Compute live balance = fetchedBalance - spend_since_last_fetch
79
84
  const liveBalance = baseBalanceNum !== null
80
85
  ? `$${Math.max(0, baseBalanceNum - (totalCost - costAtLastFetch)).toFixed(2)} USDC`
81
86
  : balance;
87
+ const showStatus = useCallback((text, tone = 'success', durationMs = 3000) => {
88
+ setStatusTone(tone);
89
+ setStatusMsg(text);
90
+ if (durationMs > 0) {
91
+ setTimeout(() => setStatusMsg(''), durationMs);
92
+ }
93
+ }, []);
94
+ const commitResponse = useCallback((text, tokens = turnTokensRef.current, cost = turnCostRef.current) => {
95
+ if (!text.trim())
96
+ return;
97
+ setCommittedResponses((rs) => [...rs, {
98
+ key: String(Date.now() + Math.random()),
99
+ text,
100
+ tokens,
101
+ cost,
102
+ }]);
103
+ const allLines = text.split('\n');
104
+ if (allLines.length > 20) {
105
+ setResponsePreview(' ↑ scroll to see full reply\n' + allLines.slice(-20).join('\n'));
106
+ }
107
+ else {
108
+ setResponsePreview('');
109
+ }
110
+ }, []);
82
111
  // Permission dialog key handler — captures y/n/a when dialog is visible.
83
112
  // ink 6.x: useInput handlers all fire regardless of TextInput focus prop,
84
113
  // so we handle here AND block TextInput onChange (see focused prop below).
@@ -110,11 +139,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
110
139
  // Escape during generation → abort current turn (skip if permission dialog open)
111
140
  if (key.escape && !ready && !permissionRequest) {
112
141
  onAbort();
113
- setStatusMsg('Aborted');
142
+ showStatus('Aborted', 'warning', 3000);
114
143
  setReady(true);
115
144
  setWaiting(false);
116
145
  setThinking(false);
117
- setTimeout(() => setStatusMsg(''), 3000);
118
146
  return;
119
147
  }
120
148
  // Esc to quit (only when input is empty and in input mode)
@@ -134,10 +162,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
134
162
  const selected = PICKER_MODELS_FLAT[pickerIdx];
135
163
  setCurrentModel(selected.id);
136
164
  onModelChange(selected.id);
137
- setStatusMsg(`Model → ${selected.label}`);
165
+ showStatus(`Model → ${selected.label}`, 'success', 3000);
138
166
  setMode('input');
139
167
  setReady(true);
140
- setTimeout(() => setStatusMsg(''), 3000);
141
168
  }
142
169
  else if (key.escape) {
143
170
  setMode('input');
@@ -169,8 +196,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
169
196
  return;
170
197
  // If agent is busy, queue the message — it will be auto-submitted when the turn finishes
171
198
  if (!ready) {
172
- setQueuedInput(trimmed);
199
+ setQueuedInputs(prev => [...prev, trimmed]);
173
200
  setInput('');
201
+ showStatus(`Queued message (${queuedInputsRef.current.length + 1} pending)`, 'warning', 1500);
174
202
  return;
175
203
  }
176
204
  // Bare exit/quit (no slash needed)
@@ -199,8 +227,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
199
227
  const resolved = resolveModel(parts[1]);
200
228
  setCurrentModel(resolved);
201
229
  onModelChange(resolved);
202
- setStatusMsg(`Model → ${resolved}`);
203
- setTimeout(() => setStatusMsg(''), 3000);
230
+ showStatus(`Model → ${resolved}`, 'success', 3000);
204
231
  }
205
232
  else {
206
233
  const idx = PICKER_MODELS_FLAT.findIndex(m => m.id === currentModel);
@@ -215,8 +242,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
215
242
  return;
216
243
  case '/cost':
217
244
  case '/usage':
218
- setStatusMsg(`Cost: $${totalCost.toFixed(4)} this session`);
219
- setTimeout(() => setStatusMsg(''), 4000);
245
+ showStatus(`Cost: $${totalCost.toFixed(4)} this session`, 'success', 4000);
220
246
  return;
221
247
  case '/help':
222
248
  setShowHelp(true);
@@ -234,8 +260,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
234
260
  return;
235
261
  case '/retry':
236
262
  if (!lastPrompt) {
237
- setStatusMsg('No previous prompt to retry');
238
- setTimeout(() => setStatusMsg(''), 3000);
263
+ showStatus('No previous prompt to retry', 'warning', 3000);
239
264
  return;
240
265
  }
241
266
  setStreamText('');
@@ -261,6 +286,13 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
261
286
  }
262
287
  }
263
288
  // ── Normal prompt ──
289
+ // Show user message in scrollback so the conversation is readable
290
+ setCommittedResponses(rs => [...rs, {
291
+ key: `user-${Date.now()}`,
292
+ text: chalk.cyan('❯') + ' ' + trimmed,
293
+ tokens: { input: 0, output: 0, calls: 0 },
294
+ cost: 0,
295
+ }]);
264
296
  setResponsePreview('');
265
297
  setLastPrompt(trimmed);
266
298
  setInputHistory(prev => [...prev.slice(-49), trimmed]); // Keep last 50
@@ -279,7 +311,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
279
311
  setTurnTokens({ input: 0, output: 0, calls: 0 });
280
312
  turnCostRef.current = 0;
281
313
  onSubmit(trimmed);
282
- }, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory]);
314
+ }, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]);
283
315
  // Expose event handler, balance updater, and permission bridge
284
316
  useEffect(() => {
285
317
  globalThis.__runcode_ui = {
@@ -387,27 +419,24 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
387
419
  break;
388
420
  }
389
421
  case 'turn_done': {
390
- // Commit full response to Static immediately — enters terminal scrollback like Claude Code.
391
- // Also keep a short preview (last 5 lines) visible in the dynamic area.
392
422
  const text = streamTextRef.current;
393
423
  if (text.trim()) {
394
- setCommittedResponses(rs => [...rs, {
395
- key: String(Date.now()),
396
- text,
397
- tokens: turnTokensRef.current,
398
- cost: turnCostRef.current, // per-turn cost, not cumulative
399
- }]);
400
- // Preview = only show when response is long enough to scroll off screen
401
- const allLines = text.split('\n');
402
- if (allLines.length > 20) {
403
- setResponsePreview(' ↑ scroll to see full reply\n' + allLines.slice(-20).join('\n'));
404
- }
405
- else {
406
- // Short response: already fully visible in scrollback, no preview needed
407
- setResponsePreview('');
408
- }
424
+ commitResponse(text, turnTokensRef.current, turnCostRef.current);
409
425
  setStreamText('');
410
426
  }
427
+ if (event.reason === 'error' && event.error) {
428
+ commitResponse(`Error: ${event.error}`, turnTokensRef.current, turnCostRef.current);
429
+ showStatus('Turn failed', 'error', 5000);
430
+ }
431
+ else if (event.reason === 'aborted') {
432
+ showStatus('Aborted', 'warning', 3000);
433
+ }
434
+ else if (event.reason === 'max_turns') {
435
+ showStatus('Stopped after reaching max turns', 'warning', 5000);
436
+ }
437
+ else {
438
+ setStatusMsg('');
439
+ }
411
440
  setReady(true);
412
441
  setWaiting(false);
413
442
  setThinking(false);
@@ -417,10 +446,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
417
446
  // Ring the terminal bell so the user knows the AI finished
418
447
  // (shows notification badge in iTerm2/Terminal.app when tabbed away)
419
448
  process.stderr.write('\x07');
420
- // Auto-submit any message queued while agent was busy
421
- const queued = queuedInputRef.current;
449
+ // Auto-submit any queued message while agent was busy
450
+ const queued = queuedInputsRef.current[0];
422
451
  if (queued) {
423
- setQueuedInput('');
452
+ setQueuedInputs((prev) => prev.slice(1));
424
453
  // Small delay so React can flush the ready=true state first
425
454
  setTimeout(() => {
426
455
  const fn = globalThis.__runcode_submit;
@@ -440,7 +469,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
440
469
  delete globalThis.__runcode_ui;
441
470
  delete globalThis.__runcode_submit;
442
471
  };
443
- }, [handleSubmit]);
472
+ }, [handleSubmit, commitResponse, showStatus]);
444
473
  // ── Render ──
445
474
  // Note: the tree is ALWAYS the same shape across mode changes. Static
446
475
  // components (completedTools, committedResponses) stay mounted so Ink
@@ -448,9 +477,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
448
477
  // opens/closes. The picker is rendered inline below scrollback, and the
449
478
  // InputBox is hidden while it's active.
450
479
  const inPicker = mode === 'model-picker';
451
- return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "green", children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/retry" }), " Retry the last prompt"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/compact" }), " Compress conversation history"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Coding \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/test" }), " Run tests"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/fix" }), " Fix last error"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/review" }), " Code review"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/explain" }), " file Explain code"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/search" }), " query Search codebase"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/refactor" }), " desc Refactor code"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/scaffold" }), " desc Generate boilerplate"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Git \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/commit" }), " Commit changes"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/push" }), " Push to remote"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/pr" }), " Create pull request"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/status" }), " Git status"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/diff" }), " Git diff"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/log" }), " Git log"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/branch" }), " [name] Branches"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/stash" }), " Stash changes"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/undo" }), " Undo last commit"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Analysis \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/security" }), " Security audit"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/lint" }), " Quality check"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/optimize" }), " Performance check"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/todo" }), " Find TODOs"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/deps" }), " Dependencies"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clean" }), " Dead code removal"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/context" }), " Session info (model, tokens, mode)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/plan" }), " Enter plan mode (read-only tools)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/execute" }), " Exit plan mode (enable all tools)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/sessions" }), " List saved sessions"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/resume" }), " id Resume a saved session"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation display"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/doctor" }), " Diagnose setup issues"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), _jsx(Static, { items: completedTools, children: (tool) => (_jsx(Box, { marginLeft: 1, children: tool.error
480
+ return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: statusTone === 'error' ? 'red' : statusTone === 'warning' ? 'yellow' : 'green', children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/retry" }), " Retry the last prompt"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/compact" }), " Compress conversation history"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Coding \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/test" }), " Run tests"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/fix" }), " Fix last error"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/review" }), " Code review"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/explain" }), " file Explain code"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/search" }), " query Search codebase"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/session-search" }), " q Search past sessions"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/refactor" }), " desc Refactor code"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/scaffold" }), " desc Generate boilerplate"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Git \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/commit" }), " Commit changes"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/push" }), " Push to remote"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/pr" }), " Create pull request"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/status" }), " Git status"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/diff" }), " Git diff"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/log" }), " Git log"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/branch" }), " [name] Branches"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/stash" }), " Stash changes"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/undo" }), " Undo last commit"] }), _jsx(Text, { dimColor: true, children: " \u2500\u2500 Analysis \u2500\u2500" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/security" }), " Security audit"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/lint" }), " Quality check"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/optimize" }), " Performance check"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/todo" }), " Find TODOs"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/deps" }), " Dependencies"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clean" }), " Dead code removal"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/context" }), " Session info (model, tokens, mode)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/plan" }), " Enter plan mode (read-only tools)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/execute" }), " Exit plan mode (enable all tools)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/sessions" }), " List saved sessions"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/resume" }), " id Resume a saved session"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation history"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/doctor" }), " Diagnose setup issues"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), _jsx(Static, { items: completedTools, children: (tool) => (_jsx(Box, { marginLeft: 1, children: tool.error
452
481
  ? _jsxs(Text, { color: "red", children: [" \u2717 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms", tool.preview ? ` — ${tool.preview}` : ''] })] })
453
- : _jsxs(Text, { color: "green", children: [" \u2713 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms", tool.preview ? ` — ${tool.preview}` : ''] })] }) }, tool.key)) }), _jsx(Static, { items: committedResponses, children: (r) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "wrap", children: r.text }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [r.tokens.calls > 0 && r.tokens.input === 0
482
+ : _jsxs(Text, { color: "green", children: [" \u2713 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms", tool.preview ? ` — ${tool.preview}` : ''] })] }) }, tool.key)) }), _jsx(Static, { items: committedResponses, children: (r) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [r.tokens.calls > 0 && r.tokens.input === 0
454
483
  ? `${r.tokens.calls} calls`
455
484
  : `${r.tokens.input.toLocaleString()} in / ${r.tokens.output.toLocaleString()} out${r.tokens.calls > 0 ? ` / ${r.tokens.calls} calls` : ''}`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : ''] }) }))] }, r.key)) }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 1, children: [_jsx(Text, { color: "yellow", children: " \u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: [" \u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: [" \u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 3, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 1, children: [_jsx(Text, { color: "cyan", children: " \u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: [" \u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: [" \u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 3, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
456
485
  const answer = val.trim() || '(no response)';
@@ -458,7 +487,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
458
487
  setAskUserRequest(null);
459
488
  setAskUserInput('');
460
489
  r(answer);
461
- }, focus: true })] })] })), Array.from(tools.entries()).map(([id, tool]) => (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "cyan", children: [' ', _jsx(Spinner, { type: "dots" }), ' ', tool.name, tool.preview ? _jsxs(Text, { dimColor: true, children: [": ", tool.preview] }) : null, _jsx(Text, { dimColor: true, children: (() => { const s = Math.round((Date.now() - tool.startTime) / 1000); return s > 0 ? ` ${s}s` : ''; })() })] }), tool.liveOutput ? (_jsxs(Text, { dimColor: true, children: [" \u2514 ", tool.liveOutput] })) : null] }, id))), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking..."] }), thinkingText && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", thinkingText.split('\n').pop()?.slice(0, 80)] }))] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsx(Text, { dimColor: true, children: currentModel })] }) })), streamText && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: streamText }) })), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: responsePreview }) })), inPicker && (() => {
490
+ }, focus: true })] })] })), Array.from(tools.entries()).map(([id, tool]) => (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "cyan", children: [' ', _jsx(Spinner, { type: "dots" }), ' ', tool.name, tool.preview ? _jsxs(Text, { dimColor: true, children: [": ", tool.preview] }) : null, _jsx(Text, { dimColor: true, children: (() => { const s = Math.round((Date.now() - tool.startTime) / 1000); return s > 0 ? ` ${s}s` : ''; })() })] }), tool.liveOutput ? (_jsxs(Text, { dimColor: true, children: [" \u2514 ", tool.liveOutput] })) : null] }, id))), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking..."] }), thinkingText && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", thinkingText.split('\n').pop()?.slice(0, 80)] }))] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsx(Text, { dimColor: true, children: currentModel })] }) })), streamText && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(streamText) }) })), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
462
491
  let flatIdx = 0;
463
492
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), PICKER_CATEGORIES.map((cat) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), cat.models.map((m) => {
464
493
  const myIdx = flatIdx++;
@@ -467,7 +496,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
467
496
  const isHighlight = m.highlight === true;
468
497
  return (_jsxs(Box, { marginLeft: 2, children: [_jsxs(Text, { inverse: isSelected, color: isSelected ? 'cyan' : isHighlight ? 'yellow' : undefined, bold: isSelected || isHighlight, children: [' ', m.label.padEnd(26), ' '] }), _jsxs(Text, { dimColor: true, children: [" ", m.shortcut.padEnd(14)] }), _jsx(Text, { color: m.price === 'FREE' ? 'green' : isHighlight ? 'yellow' : undefined, dimColor: !isHighlight && m.price !== 'FREE', children: m.price }), isCurrent && _jsx(Text, { color: "green", children: " \u2190" })] }, m.id));
469
498
  })] }, cat.category))), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { dimColor: true, children: "Your conversation stays above \u2014 picking a model keeps all history intact." }) })] }));
470
- })(), !inPicker && (_jsx(InputBox, { input: (permissionRequest || askUserRequest) ? '' : input, setInput: (permissionRequest || askUserRequest) ? () => { } : setInput, onSubmit: (permissionRequest || askUserRequest) ? () => { } : handleSubmit, model: currentModel, balance: liveBalance, sessionCost: totalCost, queued: queuedInput || undefined, focused: !permissionRequest && !askUserRequest, busy: !askUserRequest && (waiting || thinking || tools.size > 0) }))] }));
499
+ })(), !inPicker && (_jsx(InputBox, { input: (permissionRequest || askUserRequest) ? '' : input, setInput: (permissionRequest || askUserRequest) ? () => { } : setInput, onSubmit: (permissionRequest || askUserRequest) ? () => { } : handleSubmit, model: currentModel, balance: liveBalance, sessionCost: totalCost, queued: queuedInputs[0] || undefined, queuedCount: queuedInputs.length, focused: !permissionRequest && !askUserRequest, busy: !askUserRequest && (waiting || thinking || tools.size > 0) }))] }));
471
500
  }
472
501
  export function launchInkUI(opts) {
473
502
  let resolveInput = null;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Markdown renderer for terminal output.
3
+ * Converts markdown to ANSI-formatted text using chalk.
4
+ * Shared between Ink UI and basic terminal UI.
5
+ */
6
+ /**
7
+ * Render a complete markdown string to ANSI-colored terminal output.
8
+ */
9
+ export declare function renderMarkdown(text: string): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Markdown renderer for terminal output.
3
+ * Converts markdown to ANSI-formatted text using chalk.
4
+ * Shared between Ink UI and basic terminal UI.
5
+ */
6
+ import chalk from 'chalk';
7
+ /**
8
+ * Render a complete markdown string to ANSI-colored terminal output.
9
+ */
10
+ export function renderMarkdown(text) {
11
+ const lines = text.split('\n');
12
+ const out = [];
13
+ let inCodeBlock = false;
14
+ for (const line of lines) {
15
+ // Code block toggle
16
+ if (line.startsWith('```')) {
17
+ inCodeBlock = !inCodeBlock;
18
+ out.push(chalk.dim(line));
19
+ continue;
20
+ }
21
+ if (inCodeBlock) {
22
+ out.push(chalk.cyan(line));
23
+ continue;
24
+ }
25
+ // Headers
26
+ if (line.startsWith('### ')) {
27
+ out.push(chalk.bold(line.slice(4)));
28
+ continue;
29
+ }
30
+ if (line.startsWith('## ')) {
31
+ out.push(chalk.bold.underline(line.slice(3)));
32
+ continue;
33
+ }
34
+ if (line.startsWith('# ')) {
35
+ out.push(chalk.bold.underline(line.slice(2)));
36
+ continue;
37
+ }
38
+ // Horizontal rule
39
+ if (/^[-=─]{3,}$/.test(line.trim())) {
40
+ out.push(chalk.dim('─'.repeat(40)));
41
+ continue;
42
+ }
43
+ // Blockquotes
44
+ if (line.startsWith('> ')) {
45
+ out.push(chalk.dim('│ ') + chalk.italic(renderInline(line.slice(2))));
46
+ continue;
47
+ }
48
+ // Bullet points
49
+ if (line.match(/^(\s*)[-*] /)) {
50
+ out.push(line.replace(/^(\s*)[-*] /, '$1• ').replace(/^(\s*• )(.*)/, (_, prefix, rest) => prefix + renderInline(rest)));
51
+ continue;
52
+ }
53
+ // Table rows — render with dim separators
54
+ if (line.includes('|') && line.trim().startsWith('|')) {
55
+ // Separator row (|---|---|)
56
+ if (/^\s*\|[\s-:]+\|/.test(line) && !line.match(/[a-zA-Z]/)) {
57
+ out.push(chalk.dim(line));
58
+ continue;
59
+ }
60
+ // Data row — bold headers in first row, dim pipes
61
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
62
+ const formatted = cells.map(c => renderInline(c)).join(chalk.dim(' │ '));
63
+ out.push(chalk.dim('│ ') + formatted + chalk.dim(' │'));
64
+ continue;
65
+ }
66
+ // Everything else — inline formatting
67
+ out.push(renderInline(line));
68
+ }
69
+ return out.join('\n');
70
+ }
71
+ /**
72
+ * Render inline markdown formatting (bold, italic, code, links).
73
+ */
74
+ function renderInline(text) {
75
+ return text
76
+ // Inline code (process first to protect contents from other formatting)
77
+ .replace(/`([^`]+)`/g, (_, t) => chalk.cyan(t))
78
+ // Bold
79
+ .replace(/\*\*([^*]+)\*\*/g, (_, t) => chalk.bold(t))
80
+ // Italic
81
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, t) => chalk.italic(t))
82
+ // Strikethrough
83
+ .replace(/~~([^~]+)~~/g, (_, t) => chalk.strikethrough(t))
84
+ // Links — show label in blue, URL dimmed
85
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => chalk.blue.underline(label) + chalk.dim(` (${url})`));
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
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": {