@blockrun/franklin 3.5.0 → 3.6.1

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/README.md CHANGED
@@ -121,31 +121,20 @@ Franklin is **chat-first**. You do not wire a DAG, configure six API keys, or co
121
121
 
122
122
  Live data from CoinGecko. RSI, MACD, Bollinger, and volatility computed locally. No API key needed.
123
123
 
124
- ### 🎯 Social growth
124
+ ### 🎨 Image generation
125
125
 
126
126
  ```text
127
- > find X posts complaining about AI rate limits
127
+ > generate a logo for my AI startup — minimalist, dark background
128
128
 
129
- SearchX "AI rate limits"
130
-
131
- Found 8 candidates:
132
- 1. "Claude keeps throttling me in the middle of shipping..." — @buildermax (2h)
133
- 2. "I need an agent that can switch models automatically." — @indiedev (5h)
134
- ...
135
-
136
- > write a reply to #2 — mention Franklin uses a wallet instead of subscriptions
137
-
138
- Draft:
139
- "That was my pain too. Franklin routes across 55+ models,
140
- pays per action from a USDC wallet, and doesn't trap you
141
- inside a monthly seat. Better economics, better uptime."
129
+ ImageGen "minimalist AI startup logo, dark background..."
130
+ Saved: generated-logo-1713052800.png (1024x1024)
131
+ ```
142
132
 
143
- > looks good, post it
133
+ Generates images via DALL-E / GPT Image directly from the CLI. Paid from your wallet — no OpenAI API key needed.
144
134
 
145
- PostToX Reply posted to x.com/indiedev/status/...
146
- ```
135
+ ### 🎯 Social growth (with setup)
147
136
 
148
- Search X, generate contextual replies, and post with confirmation. Uses Playwright for browser automation, so there is no X API key, no OAuth maze, and no $100/month developer account.
137
+ After running `franklin social setup && franklin social login x`, Franklin can search X, draft replies, and post with your confirmation no X API key or developer account needed.
149
138
 
150
139
  ### 🔎 Research, code, anything with a budget
151
140
 
@@ -286,8 +275,8 @@ Franklin can decide what is worth paying for, route the call, sign the micropaym
286
275
  **📈 Trading signals**
287
276
  Ask "what's BTC looking like?" — Franklin fetches live price data, computes RSI/MACD/Bollinger/volatility, and synthesizes a signal.
288
277
 
289
- **🎯 Social growth**
290
- Ask "find X posts about my category" — Franklin searches X, drafts replies, and posts with your confirmation.
278
+ **🎨 AI image generation**
279
+ Ask "generate a logo" — Franklin calls DALL-E / GPT Image, saves the result locally, paid from your wallet.
291
280
 
292
281
  **🧠 55+ models via one wallet**
293
282
  Anthropic, OpenAI, Google, xAI, DeepSeek, GLM, Kimi, Minimax, NVIDIA free tier. One wallet, one interface, automatic fallback.
@@ -174,6 +174,38 @@ export class StreamingExecutor {
174
174
  }
175
175
  : this.scope;
176
176
  try {
177
+ // Runtime input validation: check required fields and types
178
+ const schema = handler.spec.input_schema;
179
+ if (schema?.required) {
180
+ for (const field of schema.required) {
181
+ if (invocation.input[field] === undefined || invocation.input[field] === null) {
182
+ const desc = schema.properties?.[field]?.description || '';
183
+ return {
184
+ output: `Error: missing required parameter "${field}" for ${handler.spec.name}. ${desc}`,
185
+ isError: true,
186
+ };
187
+ }
188
+ }
189
+ }
190
+ // Type coercion for common model mistakes (string↔number, string↔boolean)
191
+ if (schema?.properties) {
192
+ for (const [key, value] of Object.entries(invocation.input)) {
193
+ if (value == null)
194
+ continue;
195
+ const prop = schema.properties[key];
196
+ if (!prop?.type)
197
+ continue;
198
+ if (prop.type === 'number' && typeof value === 'string' && !isNaN(Number(value))) {
199
+ invocation.input[key] = Number(value);
200
+ }
201
+ else if (prop.type === 'boolean' && typeof value === 'string') {
202
+ if (value === 'true')
203
+ invocation.input[key] = true;
204
+ else if (value === 'false')
205
+ invocation.input[key] = false;
206
+ }
207
+ }
208
+ }
177
209
  let result = await handler.execute(invocation.input, progressScope);
178
210
  this.guard?.afterExecute(invocation, result);
179
211
  // Persist large results to disk with preview (inspired by Claude Code toolResultStorage)
@@ -51,6 +51,15 @@ export interface CapabilityHandler {
51
51
  export interface CapabilityResult {
52
52
  output: string;
53
53
  isError?: boolean;
54
+ /** Structured diff for Edit tool — enables colored diff display in UI. */
55
+ diff?: {
56
+ file: string;
57
+ oldLines: string[];
58
+ newLines: string[];
59
+ count: number;
60
+ };
61
+ /** Full tool output for expandable display — separate from truncated preview. */
62
+ fullOutput?: string;
54
63
  }
55
64
  export interface ExecutionScope {
56
65
  workingDir: string;
@@ -74,10 +74,9 @@ export async function startCommand(options) {
74
74
  // After the user's first session, the tip fades and they go straight to the prompt.
75
75
  console.log('');
76
76
  console.log(chalk.dim(' Try something only Franklin can do:'));
77
- console.log(chalk.dim(' ') + chalk.hex('#FFD700')('"what\'s BTC looking like today?"') + chalk.dim(' ← market signal'));
78
- console.log(chalk.dim(' ') + chalk.hex('#10B981')('"find X posts about ai agent"') + chalk.dim(' ← social growth'));
79
- console.log(chalk.dim(' ') + chalk.hex('#60A5FA')('"generate a hero image for my app"') + chalk.dim(' ← image gen'));
80
- console.log(chalk.dim(' Or just code — 55+ models, no API keys.'));
77
+ console.log(chalk.dim(' ') + chalk.hex('#FFD700')('"what\'s BTC looking like today?"') + chalk.dim(' ← live market data'));
78
+ console.log(chalk.dim(' ') + chalk.hex('#60A5FA')('"generate a logo for my startup"') + chalk.dim(' ← AI image gen'));
79
+ console.log(chalk.dim(' Code with 55+ models. No API keys. Pay per use.'));
81
80
  console.log('');
82
81
  // Balance fetcher — used at startup and after each turn
83
82
  const fetchBalance = async () => {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { partiallyReadFiles, fileReadTracker } from './read.js';
6
+ import { partiallyReadFiles, fileReadTracker, invalidateFileCache } from './read.js';
7
7
  /**
8
8
  * Normalize curly/smart quotes to straight quotes.
9
9
  * Claude Code does this to handle API-sanitized strings and editor paste artifacts.
@@ -143,8 +143,9 @@ async function execute(input, ctx) {
143
143
  updated = content.slice(0, firstIdx) + newStr + content.slice(firstIdx + effectiveOldStr.length);
144
144
  }
145
145
  fs.writeFileSync(resolved, updated, 'utf-8');
146
- // File has been modified — remove from partial-read tracking so next read is fresh
146
+ // File has been modified — invalidate caches so next read is fresh
147
147
  partiallyReadFiles.delete(resolved);
148
+ invalidateFileCache(resolved);
148
149
  // Update read tracker mtime so subsequent edits don't trigger stale-write detection
149
150
  const newStat = fs.statSync(resolved);
150
151
  fileReadTracker.set(resolved, { mtimeMs: newStat.mtimeMs, readAt: Date.now() });
@@ -172,6 +173,7 @@ async function execute(input, ctx) {
172
173
  }
173
174
  return {
174
175
  output: `Updated ${resolved} — ${matchCount} replacement${matchCount > 1 ? 's' : ''} made.${diffPreview}${partialWarning}`,
176
+ diff: { file: resolved, oldLines, newLines, count: matchCount },
175
177
  };
176
178
  }
177
179
  catch (err) {
@@ -14,7 +14,7 @@ async function execute(input, ctx) {
14
14
  const chain = loadChain();
15
15
  const apiUrl = API_URLS[chain];
16
16
  const endpoint = `${apiUrl}/v1/images/generations`;
17
- const imageModel = model || 'dall-e-3';
17
+ const imageModel = model || 'openai/gpt-image-1';
18
18
  const imageSize = size || '1024x1024';
19
19
  // Default output path
20
20
  const outPath = output_path
@@ -162,7 +162,7 @@ export const imageGenCapability = {
162
162
  prompt: { type: 'string', description: 'Text description of the image to generate' },
163
163
  output_path: { type: 'string', description: 'Where to save the image. Default: generated-<timestamp>.png in working directory' },
164
164
  size: { type: 'string', description: 'Image size: 1024x1024, 1792x1024, or 1024x1792. Default: 1024x1024' },
165
- model: { type: 'string', description: 'Image model to use. Default: dall-e-3' },
165
+ model: { type: 'string', description: 'Image model to use. Default: openai/gpt-image-1' },
166
166
  },
167
167
  required: ['prompt'],
168
168
  },
@@ -22,4 +22,6 @@ export declare const fileReadTracker: Map<string, {
22
22
  mtimeMs: number;
23
23
  readAt: number;
24
24
  }>;
25
+ /** Invalidate the content cache for a file (call after Edit/Write modifies it). */
26
+ export declare function invalidateFileCache(resolvedPath: string): void;
25
27
  export declare const readCapability: CapabilityHandler;
@@ -16,6 +16,24 @@ export const partiallyReadFiles = new Map();
16
16
  * Exported so edit.ts and write.ts can check.
17
17
  */
18
18
  export const fileReadTracker = new Map();
19
+ /**
20
+ * File state cache — avoids re-reading unchanged files across turns.
21
+ * Stores mtime + line count for each file. If the model requests a Read
22
+ * and the file hasn't changed (same mtime), return a short stub instead
23
+ * of the full content. This saves thousands of tokens on repeated reads.
24
+ *
25
+ * Cache is invalidated when:
26
+ * - File mtime changes (edited externally or by Edit/Write tool)
27
+ * - Different offset/limit is requested (user wants a different section)
28
+ */
29
+ const fileContentCache = new Map();
30
+ function cacheKey(resolved, offset, limit) {
31
+ return `${offset ?? 0}:${limit ?? 2000}`;
32
+ }
33
+ /** Invalidate the content cache for a file (call after Edit/Write modifies it). */
34
+ export function invalidateFileCache(resolvedPath) {
35
+ fileContentCache.delete(resolvedPath);
36
+ }
19
37
  async function execute(input, ctx) {
20
38
  const { file_path: filePath, offset, limit } = input;
21
39
  if (!filePath) {
@@ -24,6 +42,14 @@ async function execute(input, ctx) {
24
42
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath);
25
43
  try {
26
44
  const stat = fs.statSync(resolved);
45
+ // File state cache: if file hasn't changed and same range requested, return stub
46
+ const range = cacheKey(resolved, offset, limit);
47
+ const cached = fileContentCache.get(resolved);
48
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.readRange === range) {
49
+ return {
50
+ output: `File unchanged since last read (${cached.lineCount} lines). Content is already in your context — do not re-read it.`,
51
+ };
52
+ }
27
53
  if (stat.isDirectory()) {
28
54
  // Helpfully list directory contents instead of just erroring
29
55
  const entries = fs.readdirSync(resolved, { withFileTypes: true });
@@ -65,6 +91,8 @@ async function execute(input, ctx) {
65
91
  }
66
92
  // Record this read for read-before-edit/write enforcement
67
93
  fileReadTracker.set(resolved, { mtimeMs: stat.mtimeMs, readAt: Date.now() });
94
+ // Update file state cache (for cross-turn dedup)
95
+ fileContentCache.set(resolved, { mtimeMs: stat.mtimeMs, lineCount: allLines.length, readRange: range });
68
96
  // Format with line numbers (cat -n style)
69
97
  const numbered = slice.map((line, i) => `${startLine + i + 1}\t${line}`);
70
98
  let result = numbered.join('\n');
@@ -4,7 +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, fileReadTracker } from './read.js';
7
+ import { partiallyReadFiles, fileReadTracker, invalidateFileCache } from './read.js';
8
8
  function withTrailingSep(value) {
9
9
  return value.endsWith(path.sep) ? value : value + path.sep;
10
10
  }
@@ -93,6 +93,7 @@ async function execute(input, ctx) {
93
93
  fs.mkdirSync(parentDir, { recursive: true });
94
94
  fs.writeFileSync(resolved, content, 'utf-8');
95
95
  partiallyReadFiles.delete(resolved);
96
+ invalidateFileCache(resolved);
96
97
  // Update read tracker so subsequent edits don't trigger stale detection
97
98
  const newStat = fs.statSync(resolved);
98
99
  fileReadTracker.set(resolved, { mtimeMs: newStat.mtimeMs, readAt: Date.now() });
package/dist/ui/app.js CHANGED
@@ -8,12 +8,13 @@ import { useState, useEffect, useCallback, useRef } from 'react';
8
8
  import { render, Static, Box, Text, useApp, useInput, useStdout } from 'ink';
9
9
  import Spinner from 'ink-spinner';
10
10
  import TextInput from 'ink-text-input';
11
+ import VimInput from './vim-input.js';
11
12
  import { renderMarkdown } from './markdown.js';
12
13
  import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-picker.js';
13
14
  import { estimateCost } from '../pricing.js';
14
15
  import { formatTokens, shortModelName } from '../stats/format.js';
15
16
  // ─── Full-width input box ──────────────────────────────────────────────────
16
- function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct }) {
17
+ function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct, vimMode, onVimModeChange }) {
17
18
  const { stdout } = useStdout();
18
19
  const cols = stdout?.columns ?? 80;
19
20
  const innerWidth = Math.min(Math.max(30, cols - 4), cols - 2);
@@ -22,7 +23,13 @@ function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queu
22
23
  ? `⏎ ${queuedCount ?? 1} queued: ${queued.slice(0, 40)}`
23
24
  : 'Working...')
24
25
  : 'Type a message...';
25
- 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)] }) : '', contextPct !== undefined && contextPct > 0 ? (_jsxs(Text, { color: contextPct > 85 ? 'red' : contextPct > 70 ? 'yellow' : undefined, children: [' · ctx ', contextPct, '%'] })) : null, (queuedCount ?? 0) > 0 ? _jsxs(Text, { color: "cyan", children: [" \u00B7 ", queuedCount, " queued"] }) : null, ' · esc'] }) })] }));
26
+ 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: 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 })) }), _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 ? ' ' : '', shortModelName(model), " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', contextPct !== undefined && contextPct > 0 ? (() => {
27
+ // Visual context bar: ▓▓▓▓▓▓░░░░ 75%
28
+ const filled = Math.round(contextPct / 10);
29
+ const empty = 10 - filled;
30
+ const barColor = contextPct > 85 ? 'red' : contextPct > 70 ? 'yellow' : 'green';
31
+ return (_jsxs(Text, { children: [' ', _jsx(Text, { color: barColor, children: '▓'.repeat(filled) }), _jsx(Text, { dimColor: true, children: '░'.repeat(empty) }), _jsxs(Text, { color: barColor, children: [' ', contextPct, "%"] })] }));
32
+ })() : null, (queuedCount ?? 0) > 0 ? _jsxs(Text, { color: "cyan", children: [" \u00B7 ", queuedCount, " queued"] }) : null, ' · esc'] }) })] }));
26
33
  }
27
34
  function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) {
28
35
  const { exit } = useApp();
@@ -33,6 +40,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
33
40
  const [tools, setTools] = useState(new Map());
34
41
  // Completed tool results committed to Static (permanent scrollback — no re-render artifacts)
35
42
  const [completedTools, setCompletedTools] = useState([]);
43
+ // Last completed tool — shown in dynamic area so it can be expanded/collapsed with Tab
44
+ const [expandableTool, setExpandableTool] = useState(null);
36
45
  // Full responses committed to Static immediately — goes into terminal scrollback like Claude Code
37
46
  const [committedResponses, setCommittedResponses] = useState([]);
38
47
  // Short preview of latest response shown in dynamic area (last ~5 lines, cleared on next turn)
@@ -48,6 +57,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
48
57
  const [totalCost, setTotalCost] = useState(0);
49
58
  const [showHelp, setShowHelp] = useState(false);
50
59
  const [showWallet, setShowWallet] = useState(false);
60
+ const [vimEnabled, setVimEnabled] = useState(false);
61
+ const [currentVimMode, setCurrentVimMode] = useState('insert');
51
62
  const [balance, setBalance] = useState(walletBalance);
52
63
  // Parse the fetched balance to a number so we can compute live balance = fetchedBalance - sessionCost.
53
64
  // costAtLastFetch tracks totalCost when balance was last fetched, to avoid double-subtracting.
@@ -154,7 +165,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
154
165
  return;
155
166
  }
156
167
  // Esc to quit (only when input is empty and in input mode)
168
+ // In Vim mode: Esc goes to normal mode (handled by VimInput), only quit on Esc in normal mode with empty input
157
169
  if (key.escape && mode === 'input' && ready && !input) {
170
+ if (vimEnabled && currentVimMode === 'insert')
171
+ return; // Let VimInput handle Esc → normal
158
172
  onExit();
159
173
  exit();
160
174
  return;
@@ -179,6 +193,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
179
193
  setReady(true);
180
194
  }
181
195
  }, { isActive: isPickerOrEsc });
196
+ // Tab key: toggle expand/collapse on the last completed tool
197
+ useInput((_ch, key) => {
198
+ if (key.tab && expandableTool) {
199
+ setExpandableTool(prev => prev ? { ...prev, expanded: !prev.expanded } : null);
200
+ }
201
+ }, { isActive: mode === 'input' && !permissionRequest && !askUserRequest });
182
202
  // Input history: Up/Down arrow when in ready input mode
183
203
  useInput((_ch, key) => {
184
204
  if (key.upArrow && inputHistory.length > 0) {
@@ -256,6 +276,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
256
276
  setShowHelp(true);
257
277
  setShowWallet(false);
258
278
  return;
279
+ case '/vim':
280
+ setVimEnabled(prev => !prev);
281
+ showStatus(vimEnabled ? 'Vim mode OFF' : 'Vim mode ON — Esc for normal, i for insert', 'success', 3000);
282
+ return;
259
283
  case '/clear':
260
284
  setStreamText('');
261
285
  setTools(new Map());
@@ -316,6 +340,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
316
340
  setThinking(false);
317
341
  setThinkingText('');
318
342
  setTools(new Map());
343
+ // Flush expandable tool to Static before clearing
344
+ setExpandableTool(prev => {
345
+ if (prev)
346
+ setCompletedTools(prev2 => [...prev2, { ...prev, expanded: false }]);
347
+ return null;
348
+ });
319
349
  setCompletedTools([]);
320
350
  setReady(false);
321
351
  setWaiting(true);
@@ -382,6 +412,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
382
412
  done: false, error: false,
383
413
  preview: event.preview || '',
384
414
  liveOutput: '',
415
+ liveLines: [],
416
+ fullOutput: '',
417
+ expanded: false,
385
418
  elapsed: 0,
386
419
  });
387
420
  return next;
@@ -393,7 +426,13 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
393
426
  if (!t || t.done)
394
427
  return prev;
395
428
  const next = new Map(prev);
396
- next.set(event.id, { ...t, liveOutput: event.text });
429
+ // Accumulate output lines for multi-line display (keep last 5)
430
+ const newLines = [...t.liveLines];
431
+ const incoming = event.text.split('\n').filter(Boolean);
432
+ newLines.push(...incoming);
433
+ while (newLines.length > 5)
434
+ newLines.shift();
435
+ next.set(event.id, { ...t, liveOutput: event.text, liveLines: newLines });
397
436
  return next;
398
437
  });
399
438
  break;
@@ -413,10 +452,19 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
413
452
  error: !!event.result.isError,
414
453
  preview: resultPreview,
415
454
  liveOutput: '',
455
+ liveLines: [],
456
+ fullOutput: event.result.output || '',
457
+ diff: event.result.diff,
458
+ expanded: false,
416
459
  elapsed: Date.now() - t.startTime,
417
460
  };
418
- // Move to Static (permanent scrollback) prevents re-render artifacts
419
- setCompletedTools(prev2 => [...prev2, completed]);
461
+ // Move previous expandable tool to Static, set new one as expandable
462
+ setExpandableTool(prevExpTool => {
463
+ if (prevExpTool) {
464
+ setCompletedTools(prev2 => [...prev2, { ...prevExpTool, expanded: false }]);
465
+ }
466
+ return completed;
467
+ });
420
468
  next.delete(event.id);
421
469
  }
422
470
  return next;
@@ -444,6 +492,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
444
492
  break;
445
493
  }
446
494
  case 'turn_done': {
495
+ // Flush expandable tool to Static before committing response
496
+ setExpandableTool(prev => {
497
+ if (prev)
498
+ setCompletedTools(prev2 => [...prev2, { ...prev, expanded: false }]);
499
+ return null;
500
+ });
447
501
  const text = streamTextRef.current;
448
502
  if (text.trim()) {
449
503
  commitResponse(text, turnTokensRef.current, turnCostRef.current);
@@ -502,17 +556,39 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
502
556
  // opens/closes. The picker is rendered inline below scrollback, and the
503
557
  // InputBox is hidden while it's active.
504
558
  const inPicker = mode === 'model-picker';
505
- 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
506
- ? _jsxs(Text, { color: "red", children: [" \u2717 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms", tool.preview ? ` — ${tool.preview}` : ''] })] })
507
- : _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.tier && _jsxs(Text, { color: "cyan", children: [r.tier, " "] }), r.model ? shortModelName(r.model) : '', r.model ? ' · ' : '', r.tokens.calls > 0 && r.tokens.input === 0
508
- ? `${r.tokens.calls} calls`
509
- : `${formatTokens(r.tokens.input)} in / ${formatTokens(r.tokens.output)} out`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : '', r.savings !== undefined && r.savings > 0 ? _jsxs(Text, { color: "green", children: [" saved ", Math.round(r.savings * 100), "%"] }) : ''] }) }))] }, 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) => {
559
+ 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: "/vim" }), " Toggle Vim input mode"] }), _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) => {
560
+ const elapsedFmt = tool.elapsed >= 1000
561
+ ? `${(tool.elapsed / 1000).toFixed(1)}s`
562
+ : `${tool.elapsed}ms`;
563
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { children: [tool.error
564
+ ? _jsx(Text, { color: "red", children: "\u2717" })
565
+ : _jsx(Text, { color: "green", children: "\u2713" }), ' ', _jsx(Text, { bold: true, children: tool.name }), tool.preview ? _jsxs(Text, { dimColor: true, children: ["(", tool.preview.slice(0, 80), ")"] }) : null, _jsxs(Text, { dimColor: true, children: [" ", elapsedFmt] })] }), tool.diff && !tool.error && tool.diff.oldLines.length <= 8 && tool.diff.newLines.length <= 8 && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [tool.diff.oldLines.map((line, i) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ['⎿ ', "- ", line.slice(0, 120)] }, `old-${i}`))), tool.diff.newLines.map((line, i) => (_jsxs(Text, { color: "green", wrap: "truncate-end", children: ['⎿ ', "+ ", line.slice(0, 120)] }, `new-${i}`)))] })), tool.diff && !tool.error && (tool.diff.oldLines.length > 8 || tool.diff.newLines.length > 8) && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ['⎿ ', tool.diff.oldLines.length, " lines \u2192 ", tool.diff.newLines.length, " lines"] }) })), tool.error && tool.fullOutput && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: tool.fullOutput.split('\n').filter(Boolean).slice(0, 3).map((line, i) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }))] }, tool.key));
566
+ } }), _jsx(Static, { items: committedResponses, children: (r) => {
567
+ const isUserMsg = r.key.startsWith('user-');
568
+ 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) }) })), _jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 1, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [r.tier && _jsxs(Text, { color: "cyan", children: ["[", r.tier, "] "] }), r.model ? shortModelName(r.model) : '', r.model ? ' · ' : '', r.tokens.calls > 0 && r.tokens.input === 0
569
+ ? `${r.tokens.calls} calls`
570
+ : `${formatTokens(r.tokens.input)} in / ${formatTokens(r.tokens.output)} out`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : '', r.savings !== undefined && r.savings > 0 ? _jsxs(Text, { color: "green", children: [" saved ", Math.round(r.savings * 100), "%"] }) : ''] }) }))] }, r.key));
571
+ } }), 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) => {
510
572
  const answer = val.trim() || '(no response)';
511
573
  const r = askUserRequest.resolve;
512
574
  setAskUserRequest(null);
513
575
  setAskUserInput('');
514
576
  r(answer);
515
- }, 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.slice(0, 60)] }) : null, _jsx(Text, { dimColor: true, children: (() => { const s = Math.round((Date.now() - tool.startTime) / 1000); return s > 0 ? ` ${s}s` : ''; })() })] }), tool.liveOutput ? (_jsxs(Text, { color: "yellow", children: [' ', tool.liveOutput.slice(0, 100)] })) : null] }, id))), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking", completedTools.length > 0 ? _jsxs(Text, { dimColor: true, children: [' ', "(step ", completedTools.length + 1, ")"] }) : null] }), thinkingText && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', thinkingText.split('\n').pop()?.slice(0, 100)] }))] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsxs(Text, { dimColor: true, children: [currentModel, completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), 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 && (() => {
577
+ }, focus: true })] })] })), expandableTool && (() => {
578
+ const tool = expandableTool;
579
+ const elapsedFmt = tool.elapsed >= 1000
580
+ ? `${(tool.elapsed / 1000).toFixed(1)}s`
581
+ : `${tool.elapsed}ms`;
582
+ const hasExpandableContent = !!(tool.diff || (tool.fullOutput && tool.fullOutput.split('\n').length > 1));
583
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { children: [tool.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), ' ', _jsx(Text, { bold: true, children: tool.name }), tool.preview ? _jsxs(Text, { dimColor: true, children: ["(", tool.preview.slice(0, 80), ")"] }) : null, _jsxs(Text, { dimColor: true, children: [" ", elapsedFmt] }), hasExpandableContent && (_jsxs(Text, { dimColor: true, children: [" ", tool.expanded ? '(tab to collapse)' : '(tab to expand)'] }))] }), !tool.expanded && tool.diff && !tool.error && tool.diff.oldLines.length <= 8 && tool.diff.newLines.length <= 8 && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [tool.diff.oldLines.map((line, i) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ['⎿ ', "- ", line.slice(0, 120)] }, `old-${i}`))), tool.diff.newLines.map((line, i) => (_jsxs(Text, { color: "green", wrap: "truncate-end", children: ['⎿ ', "+ ", line.slice(0, 120)] }, `new-${i}`)))] })), tool.expanded && tool.fullOutput && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [tool.fullOutput.split('\n').slice(0, 30).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))), tool.fullOutput.split('\n').length > 30 && (_jsxs(Text, { dimColor: true, children: ['⎿ ', "... ", tool.fullOutput.split('\n').length - 30, " more lines"] }))] })), tool.error && !tool.expanded && tool.fullOutput && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: tool.fullOutput.split('\n').filter(Boolean).slice(0, 3).map((line, i) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }))] }));
584
+ })(), Array.from(tools.entries()).map(([id, tool]) => {
585
+ const elapsed = Math.round((Date.now() - tool.startTime) / 1000);
586
+ const elapsedStr = elapsed > 0 ? ` ${elapsed}s` : '';
587
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { bold: true, color: "cyan", children: tool.name }), tool.preview ? _jsxs(Text, { dimColor: true, children: ["(", tool.preview.slice(0, 70), ")"] }) : null, _jsx(Text, { dimColor: true, children: elapsedStr })] }), tool.liveLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: tool.liveLines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }))] }, id));
588
+ }), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { color: "magenta", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsx(Text, { bold: true, children: "thinking" }), completedTools.length > 0 ? _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 step ", completedTools.length + 1] }) : null] }), thinkingText && (() => {
589
+ const lines = thinkingText.split('\n').filter(Boolean).slice(-3);
590
+ return (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: lines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }));
591
+ })()] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsxs(Text, { dimColor: true, children: [shortModelName(currentModel), completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), 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 && (() => {
516
592
  let flatIdx = 0;
517
593
  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) => {
518
594
  const myIdx = flatIdx++;
@@ -521,7 +597,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
521
597
  const isHighlight = m.highlight === true;
522
598
  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));
523
599
  })] }, 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." }) })] }));
524
- })(), !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), contextPct: contextPct }))] }));
600
+ })(), !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), contextPct: contextPct, vimMode: vimEnabled, onVimModeChange: setCurrentVimMode }))] }));
525
601
  }
526
602
  export function launchInkUI(opts) {
527
603
  let resolveInput = null;
@@ -2,6 +2,12 @@
2
2
  * Markdown renderer for terminal output.
3
3
  * Converts markdown to ANSI-formatted text using chalk.
4
4
  * Shared between Ink UI and basic terminal UI.
5
+ *
6
+ * Features beyond basic markdown:
7
+ * - Language labels on code blocks (```ts → TS)
8
+ * - Numbered list support
9
+ * - Nested blockquotes
10
+ * - Task lists (- [x] done, - [ ] todo)
5
11
  */
6
12
  /**
7
13
  * Render a complete markdown string to ANSI-colored terminal output.
@@ -2,8 +2,30 @@
2
2
  * Markdown renderer for terminal output.
3
3
  * Converts markdown to ANSI-formatted text using chalk.
4
4
  * Shared between Ink UI and basic terminal UI.
5
+ *
6
+ * Features beyond basic markdown:
7
+ * - Language labels on code blocks (```ts → TS)
8
+ * - Numbered list support
9
+ * - Nested blockquotes
10
+ * - Task lists (- [x] done, - [ ] todo)
5
11
  */
6
12
  import chalk from 'chalk';
13
+ /** Short language label for code block headers. */
14
+ const LANG_LABELS = {
15
+ ts: 'TypeScript', typescript: 'TypeScript',
16
+ js: 'JavaScript', javascript: 'JavaScript',
17
+ py: 'Python', python: 'Python',
18
+ rs: 'Rust', rust: 'Rust',
19
+ go: 'Go', golang: 'Go',
20
+ sh: 'Shell', bash: 'Shell', zsh: 'Shell', shell: 'Shell',
21
+ json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML',
22
+ sql: 'SQL', html: 'HTML', css: 'CSS', xml: 'XML',
23
+ md: 'Markdown', markdown: 'Markdown',
24
+ diff: 'Diff', dockerfile: 'Dockerfile',
25
+ c: 'C', cpp: 'C++', java: 'Java', rb: 'Ruby', ruby: 'Ruby',
26
+ swift: 'Swift', kt: 'Kotlin', kotlin: 'Kotlin',
27
+ tsx: 'TSX', jsx: 'JSX',
28
+ };
7
29
  /**
8
30
  * Render a complete markdown string to ANSI-colored terminal output.
9
31
  */
@@ -11,14 +33,41 @@ export function renderMarkdown(text) {
11
33
  const lines = text.split('\n');
12
34
  const out = [];
13
35
  let inCodeBlock = false;
36
+ let codeBlockLang = '';
14
37
  for (const line of lines) {
15
38
  // Code block toggle
16
39
  if (line.startsWith('```')) {
40
+ if (!inCodeBlock) {
41
+ // Opening — extract language
42
+ const lang = line.slice(3).trim().split(/\s/)[0].toLowerCase();
43
+ codeBlockLang = lang;
44
+ const label = LANG_LABELS[lang] || (lang ? lang.toUpperCase() : '');
45
+ out.push(chalk.dim('```') + (label ? chalk.dim.italic(` ${label}`) : ''));
46
+ }
47
+ else {
48
+ // Closing
49
+ out.push(chalk.dim('```'));
50
+ codeBlockLang = '';
51
+ }
17
52
  inCodeBlock = !inCodeBlock;
18
- out.push(chalk.dim(line));
19
53
  continue;
20
54
  }
21
55
  if (inCodeBlock) {
56
+ // Diff-style highlighting inside code blocks
57
+ if (codeBlockLang === 'diff') {
58
+ if (line.startsWith('+')) {
59
+ out.push(chalk.green(line));
60
+ continue;
61
+ }
62
+ if (line.startsWith('-')) {
63
+ out.push(chalk.red(line));
64
+ continue;
65
+ }
66
+ if (line.startsWith('@@')) {
67
+ out.push(chalk.cyan(line));
68
+ continue;
69
+ }
70
+ }
22
71
  out.push(chalk.cyan(line));
23
72
  continue;
24
73
  }
@@ -40,9 +89,21 @@ export function renderMarkdown(text) {
40
89
  out.push(chalk.dim('─'.repeat(40)));
41
90
  continue;
42
91
  }
43
- // Blockquotes
44
- if (line.startsWith('> ')) {
45
- out.push(chalk.dim('│ ') + chalk.italic(renderInline(line.slice(2))));
92
+ // Blockquotes (support nesting: >> , >>> )
93
+ const bqMatch = line.match(/^((?:>\s*)+)(.*)/);
94
+ if (bqMatch) {
95
+ const depth = (bqMatch[1].match(/>/g) || []).length;
96
+ const prefix = chalk.dim('│ '.repeat(depth));
97
+ out.push(prefix + chalk.italic(renderInline(bqMatch[2].trim())));
98
+ continue;
99
+ }
100
+ // Task lists: - [x] done, - [ ] todo
101
+ const taskMatch = line.match(/^(\s*)[-*] \[([ xX])\] (.*)/);
102
+ if (taskMatch) {
103
+ const indent = taskMatch[1];
104
+ const checked = taskMatch[2] !== ' ';
105
+ const label = taskMatch[3];
106
+ out.push(indent + (checked ? chalk.green('✓') : chalk.dim('○')) + ' ' + renderInline(label));
46
107
  continue;
47
108
  }
48
109
  // Bullet points
@@ -50,6 +111,12 @@ export function renderMarkdown(text) {
50
111
  out.push(line.replace(/^(\s*)[-*] /, '$1• ').replace(/^(\s*• )(.*)/, (_, prefix, rest) => prefix + renderInline(rest)));
51
112
  continue;
52
113
  }
114
+ // Numbered lists: 1. , 2. , etc.
115
+ const numMatch = line.match(/^(\s*)(\d+)\. (.*)/);
116
+ if (numMatch) {
117
+ out.push(numMatch[1] + chalk.dim(numMatch[2] + '.') + ' ' + renderInline(numMatch[3]));
118
+ continue;
119
+ }
53
120
  // Table rows — render with dim separators
54
121
  if (line.includes('|') && line.trim().startsWith('|')) {
55
122
  // Separator row (|---|---|)
@@ -57,7 +124,7 @@ export function renderMarkdown(text) {
57
124
  out.push(chalk.dim(line));
58
125
  continue;
59
126
  }
60
- // Data row — bold headers in first row, dim pipes
127
+ // Data row — dim pipes
61
128
  const cells = line.split('|').map(c => c.trim()).filter(Boolean);
62
129
  const formatted = cells.map(c => renderInline(c)).join(chalk.dim(' │ '));
63
130
  out.push(chalk.dim('│ ') + formatted + chalk.dim(' │'));
@@ -69,7 +136,7 @@ export function renderMarkdown(text) {
69
136
  return out.join('\n');
70
137
  }
71
138
  /**
72
- * Render inline markdown formatting (bold, italic, code, links).
139
+ * Render inline markdown formatting (bold, italic, code, links, strikethrough).
73
140
  */
74
141
  function renderInline(text) {
75
142
  return text
@@ -74,12 +74,29 @@ class MarkdownRenderer {
74
74
  }
75
75
  else {
76
76
  this.inCodeBlock = true;
77
- this.codeBlockLang = line.slice(3).trim();
78
- return chalk.dim('```' + this.codeBlockLang);
77
+ const lang = line.slice(3).trim().split(/\s/)[0].toLowerCase();
78
+ this.codeBlockLang = lang;
79
+ const LANG_LABELS = {
80
+ ts: 'TypeScript', typescript: 'TypeScript', js: 'JavaScript', javascript: 'JavaScript',
81
+ py: 'Python', python: 'Python', rs: 'Rust', rust: 'Rust', go: 'Go',
82
+ sh: 'Shell', bash: 'Shell', zsh: 'Shell', json: 'JSON', yaml: 'YAML',
83
+ sql: 'SQL', html: 'HTML', css: 'CSS', diff: 'Diff',
84
+ tsx: 'TSX', jsx: 'JSX',
85
+ };
86
+ const label = LANG_LABELS[lang] || (lang ? lang.toUpperCase() : '');
87
+ return chalk.dim('```') + (label ? chalk.dim.italic(` ${label}`) : '');
79
88
  }
80
89
  }
81
- // Inside code block — render dim
90
+ // Inside code block — diff highlighting + cyan
82
91
  if (this.inCodeBlock) {
92
+ if (this.codeBlockLang === 'diff') {
93
+ if (line.startsWith('+'))
94
+ return chalk.green(line);
95
+ if (line.startsWith('-'))
96
+ return chalk.red(line);
97
+ if (line.startsWith('@@'))
98
+ return chalk.cyan(line);
99
+ }
83
100
  return chalk.cyan(line);
84
101
  }
85
102
  // Headers
@@ -232,38 +249,38 @@ export class TerminalUI {
232
249
  const capName = cap?.name || 'unknown';
233
250
  const elapsed = cap ? Date.now() - cap.startTime : 0;
234
251
  this.activeCapabilities.delete(event.id);
235
- const timeStr = elapsed > 100 ? chalk.dim(` ${elapsed}ms`) : '';
252
+ const elapsedFmt = elapsed >= 1000
253
+ ? `${(elapsed / 1000).toFixed(1)}s`
254
+ : `${elapsed}ms`;
255
+ const timeStr = elapsed > 100 ? chalk.dim(` ${elapsedFmt}`) : '';
236
256
  if (event.result.isError) {
237
- console.error(chalk.red(` ✗ ${capName}`) +
238
- timeStr +
239
- chalk.red(`: ${truncateOutput(event.result.output, 200)}`));
257
+ console.error(chalk.red(` ✗ `) + chalk.bold(capName) +
258
+ timeStr);
259
+ // Show error preview lines
260
+ const errLines = event.result.output.split('\n').filter(Boolean).slice(0, 3);
261
+ for (const line of errLines) {
262
+ console.error(chalk.red(` ⎿ ${line.slice(0, 120)}`));
263
+ }
240
264
  }
241
265
  else {
242
- // Show diff-like output for Edit tool
243
266
  const output = event.result.output;
244
- if (capName === 'Edit' && output.includes('replacement')) {
245
- console.error(chalk.green(` ${capName}`) + timeStr + chalk.dim(` — ${output}`));
246
- }
247
- else if (capName === 'Write') {
248
- console.error(chalk.green(` ✓ ${capName}`) + timeStr + chalk.dim(` — ${output}`));
249
- }
250
- else if (capName === 'Bash') {
251
- // Show command output preview
252
- const preview = truncateOutput(output, 120);
253
- console.error(chalk.green(` ✓ ${capName}`) + timeStr);
254
- if (preview && preview !== '(no output)') {
255
- const lines = output.split('\n').slice(0, 5);
256
- for (const line of lines) {
257
- console.error(chalk.dim(` │ ${line.slice(0, 100)}`));
258
- }
259
- if (output.split('\n').length > 5) {
260
- console.error(chalk.dim(` │ ... (${output.split('\n').length - 5} more lines)`));
261
- }
267
+ const icon = chalk.green('');
268
+ console.error(` ${icon} ${chalk.bold(capName)}${timeStr}`);
269
+ if (capName === 'Bash') {
270
+ // Show last 5 lines of command output
271
+ const outLines = output.split('\n').filter(Boolean);
272
+ const show = outLines.slice(-5);
273
+ for (const line of show) {
274
+ console.error(chalk.dim(` ⎿ ${line.slice(0, 120)}`));
275
+ }
276
+ if (outLines.length > 5) {
277
+ console.error(chalk.dim(` ⎿ ... ${outLines.length - 5} more lines`));
262
278
  }
263
279
  }
264
- else {
280
+ else if (output.trim()) {
281
+ // Other tools: show first line as preview
265
282
  const preview = truncateOutput(output, 120);
266
- console.error(chalk.green(` ${capName}`) + timeStr + chalk.dim(` — ${preview}`));
283
+ console.error(chalk.dim(` ${preview}`));
267
284
  }
268
285
  }
269
286
  break;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Vim-style text input for Franklin's Ink UI.
3
+ * Supports normal/insert mode, motions, operators, counts.
4
+ *
5
+ * Normal mode: h/l/w/b/e/0/$ for movement, i/a/A/I to enter insert, x/dd/dw/D for delete
6
+ * Insert mode: standard text entry, Esc to return to normal mode
7
+ */
8
+ export type VimMode = 'insert' | 'normal';
9
+ interface VimInputProps {
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ onSubmit: (value: string) => void;
13
+ placeholder?: string;
14
+ focus?: boolean;
15
+ showMode?: boolean;
16
+ onModeChange?: (mode: VimMode) => void;
17
+ }
18
+ export default function VimInput({ value, onChange, onSubmit, placeholder, focus, showMode, onModeChange, }: VimInputProps): import("react/jsx-runtime").JSX.Element;
19
+ export {};
@@ -0,0 +1,439 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Vim-style text input for Franklin's Ink UI.
4
+ * Supports normal/insert mode, motions, operators, counts.
5
+ *
6
+ * Normal mode: h/l/w/b/e/0/$ for movement, i/a/A/I to enter insert, x/dd/dw/D for delete
7
+ * Insert mode: standard text entry, Esc to return to normal mode
8
+ */
9
+ import { useState, useCallback, useRef } from 'react';
10
+ import { Box, Text, useInput } from 'ink';
11
+ /**
12
+ * Find the start of the next word (Vim 'w' motion).
13
+ */
14
+ function nextWord(text, pos) {
15
+ let i = pos;
16
+ // Skip current word chars
17
+ while (i < text.length && /\w/.test(text[i]))
18
+ i++;
19
+ // Skip non-word non-space
20
+ while (i < text.length && /[^\w\s]/.test(text[i]))
21
+ i++;
22
+ // Skip whitespace
23
+ while (i < text.length && /\s/.test(text[i]))
24
+ i++;
25
+ return Math.min(i, text.length);
26
+ }
27
+ /**
28
+ * Find the start of the previous word (Vim 'b' motion).
29
+ */
30
+ function prevWord(text, pos) {
31
+ let i = pos - 1;
32
+ // Skip whitespace backwards
33
+ while (i > 0 && /\s/.test(text[i]))
34
+ i--;
35
+ // Skip non-word non-space backwards
36
+ if (i > 0 && /[^\w\s]/.test(text[i])) {
37
+ while (i > 0 && /[^\w\s]/.test(text[i - 1]))
38
+ i--;
39
+ return i;
40
+ }
41
+ // Skip word chars backwards
42
+ while (i > 0 && /\w/.test(text[i - 1]))
43
+ i--;
44
+ return Math.max(0, i);
45
+ }
46
+ /**
47
+ * Find the end of the current word (Vim 'e' motion).
48
+ */
49
+ function endWord(text, pos) {
50
+ let i = pos + 1;
51
+ // Skip whitespace
52
+ while (i < text.length && /\s/.test(text[i]))
53
+ i++;
54
+ // Move to end of word
55
+ while (i < text.length - 1 && /\w/.test(text[i + 1]))
56
+ i++;
57
+ return Math.min(i, text.length - 1);
58
+ }
59
+ export default function VimInput({ value, onChange, onSubmit, placeholder = '', focus = true, showMode = true, onModeChange, }) {
60
+ const [mode, setMode] = useState('insert');
61
+ const [cursor, setCursor] = useState(value.length);
62
+ const [cmdBuf, setCmdBuf] = useState(''); // accumulated command buffer (for counts + operators)
63
+ const [yankBuf, setYankBuf] = useState(''); // internal clipboard
64
+ const [undoStack, setUndoStack] = useState([]); // simple undo
65
+ const lastValueRef = useRef(value);
66
+ // Keep cursor in bounds when value changes externally
67
+ const clampedCursor = Math.min(cursor, mode === 'normal' ? Math.max(0, value.length - 1) : value.length);
68
+ const switchMode = useCallback((newMode) => {
69
+ setMode(newMode);
70
+ setCmdBuf('');
71
+ onModeChange?.(newMode);
72
+ }, [onModeChange]);
73
+ const saveUndo = useCallback(() => {
74
+ setUndoStack(prev => [...prev.slice(-20), value]);
75
+ }, [value]);
76
+ const updateValue = useCallback((newVal, newCursor) => {
77
+ onChange(newVal);
78
+ setCursor(Math.max(0, Math.min(newCursor, mode === 'normal' ? Math.max(0, newVal.length - 1) : newVal.length)));
79
+ lastValueRef.current = newVal;
80
+ }, [onChange, mode]);
81
+ useInput((input, key) => {
82
+ if (!focus)
83
+ return;
84
+ // Submit on Enter in any mode
85
+ if (key.return) {
86
+ if (mode === 'normal')
87
+ switchMode('insert');
88
+ onSubmit(value);
89
+ return;
90
+ }
91
+ // ── INSERT MODE ──
92
+ if (mode === 'insert') {
93
+ // Escape → normal mode
94
+ if (key.escape) {
95
+ const newCursor = Math.max(0, clampedCursor - 1);
96
+ setCursor(newCursor);
97
+ switchMode('normal');
98
+ return;
99
+ }
100
+ // Backspace
101
+ if (key.backspace) {
102
+ if (clampedCursor > 0) {
103
+ saveUndo();
104
+ updateValue(value.slice(0, clampedCursor - 1) + value.slice(clampedCursor), clampedCursor - 1);
105
+ }
106
+ return;
107
+ }
108
+ // Delete
109
+ if (key.delete) {
110
+ if (clampedCursor < value.length) {
111
+ saveUndo();
112
+ updateValue(value.slice(0, clampedCursor) + value.slice(clampedCursor + 1), clampedCursor);
113
+ }
114
+ return;
115
+ }
116
+ // Arrow keys in insert mode
117
+ if (key.leftArrow) {
118
+ setCursor(Math.max(0, clampedCursor - 1));
119
+ return;
120
+ }
121
+ if (key.rightArrow) {
122
+ setCursor(Math.min(value.length, clampedCursor + 1));
123
+ return;
124
+ }
125
+ if (key.upArrow || key.downArrow)
126
+ return; // let parent handle history
127
+ // Ctrl+A: beginning of line
128
+ if (key.ctrl && input === 'a') {
129
+ setCursor(0);
130
+ return;
131
+ }
132
+ // Ctrl+E: end of line
133
+ if (key.ctrl && input === 'e') {
134
+ setCursor(value.length);
135
+ return;
136
+ }
137
+ // Ctrl+W: delete word backward
138
+ if (key.ctrl && input === 'w') {
139
+ const wp = prevWord(value, clampedCursor);
140
+ saveUndo();
141
+ updateValue(value.slice(0, wp) + value.slice(clampedCursor), wp);
142
+ return;
143
+ }
144
+ // Ctrl+U: delete to beginning
145
+ if (key.ctrl && input === 'u') {
146
+ saveUndo();
147
+ setYankBuf(value.slice(0, clampedCursor));
148
+ updateValue(value.slice(clampedCursor), 0);
149
+ return;
150
+ }
151
+ // Ctrl+K: delete to end
152
+ if (key.ctrl && input === 'k') {
153
+ saveUndo();
154
+ setYankBuf(value.slice(clampedCursor));
155
+ updateValue(value.slice(0, clampedCursor), clampedCursor);
156
+ return;
157
+ }
158
+ // Skip control chars and tab
159
+ if (key.ctrl || key.meta || key.tab)
160
+ return;
161
+ // Regular character input
162
+ if (input) {
163
+ saveUndo();
164
+ updateValue(value.slice(0, clampedCursor) + input + value.slice(clampedCursor), clampedCursor + input.length);
165
+ }
166
+ return;
167
+ }
168
+ // ── NORMAL MODE ──
169
+ if (key.escape) {
170
+ setCmdBuf('');
171
+ return;
172
+ }
173
+ // Arrow keys work in normal mode too
174
+ if (key.leftArrow) {
175
+ setCursor(Math.max(0, clampedCursor - 1));
176
+ return;
177
+ }
178
+ if (key.rightArrow) {
179
+ setCursor(Math.min(Math.max(0, value.length - 1), clampedCursor + 1));
180
+ return;
181
+ }
182
+ if (key.upArrow || key.downArrow)
183
+ return; // let parent handle
184
+ // Backspace in normal mode = left
185
+ if (key.backspace) {
186
+ setCursor(Math.max(0, clampedCursor - 1));
187
+ return;
188
+ }
189
+ // Build command buffer
190
+ const fullCmd = cmdBuf + input;
191
+ // Parse count prefix
192
+ const countMatch = fullCmd.match(/^(\d+)(.*)/);
193
+ const count = countMatch ? parseInt(countMatch[1]) : 1;
194
+ const cmd = countMatch ? countMatch[2] : fullCmd;
195
+ // ── Mode switches ──
196
+ if (cmd === 'i') {
197
+ switchMode('insert');
198
+ return;
199
+ }
200
+ if (cmd === 'a') {
201
+ setCursor(Math.min(value.length, clampedCursor + 1));
202
+ switchMode('insert');
203
+ return;
204
+ }
205
+ if (cmd === 'I') {
206
+ setCursor(0);
207
+ switchMode('insert');
208
+ return;
209
+ }
210
+ if (cmd === 'A') {
211
+ setCursor(value.length);
212
+ switchMode('insert');
213
+ return;
214
+ }
215
+ if (cmd === 's') { // substitute: delete char and enter insert
216
+ saveUndo();
217
+ updateValue(value.slice(0, clampedCursor) + value.slice(clampedCursor + 1), clampedCursor);
218
+ switchMode('insert');
219
+ return;
220
+ }
221
+ if (cmd === 'S' || cmd === 'cc') { // substitute line
222
+ saveUndo();
223
+ setYankBuf(value);
224
+ updateValue('', 0);
225
+ switchMode('insert');
226
+ return;
227
+ }
228
+ // ── Navigation ──
229
+ if (cmd === 'h') {
230
+ setCursor(Math.max(0, clampedCursor - count));
231
+ setCmdBuf('');
232
+ return;
233
+ }
234
+ if (cmd === 'l') {
235
+ setCursor(Math.min(Math.max(0, value.length - 1), clampedCursor + count));
236
+ setCmdBuf('');
237
+ return;
238
+ }
239
+ if (cmd === 'w') {
240
+ let pos = clampedCursor;
241
+ for (let n = 0; n < count; n++)
242
+ pos = nextWord(value, pos);
243
+ setCursor(Math.min(pos, Math.max(0, value.length - 1)));
244
+ setCmdBuf('');
245
+ return;
246
+ }
247
+ if (cmd === 'b') {
248
+ let pos = clampedCursor;
249
+ for (let n = 0; n < count; n++)
250
+ pos = prevWord(value, pos);
251
+ setCursor(pos);
252
+ setCmdBuf('');
253
+ return;
254
+ }
255
+ if (cmd === 'e') {
256
+ let pos = clampedCursor;
257
+ for (let n = 0; n < count; n++)
258
+ pos = endWord(value, pos);
259
+ setCursor(Math.min(pos, Math.max(0, value.length - 1)));
260
+ setCmdBuf('');
261
+ return;
262
+ }
263
+ if (cmd === '0') {
264
+ // Only if not building a count (e.g., "10w" — "0" is part of count)
265
+ if (!countMatch || countMatch[2] === '0') {
266
+ setCursor(0);
267
+ setCmdBuf('');
268
+ return;
269
+ }
270
+ }
271
+ if (cmd === '$') {
272
+ setCursor(Math.max(0, value.length - 1));
273
+ setCmdBuf('');
274
+ return;
275
+ }
276
+ if (cmd === '^') {
277
+ const firstNonSpace = value.search(/\S/);
278
+ setCursor(firstNonSpace >= 0 ? firstNonSpace : 0);
279
+ setCmdBuf('');
280
+ return;
281
+ }
282
+ // ── Editing ──
283
+ if (cmd === 'x') {
284
+ if (value.length > 0) {
285
+ saveUndo();
286
+ const deleted = value.slice(clampedCursor, clampedCursor + count);
287
+ setYankBuf(deleted);
288
+ const newVal = value.slice(0, clampedCursor) + value.slice(clampedCursor + count);
289
+ updateValue(newVal, Math.min(clampedCursor, Math.max(0, newVal.length - 1)));
290
+ }
291
+ setCmdBuf('');
292
+ return;
293
+ }
294
+ if (cmd === 'X') {
295
+ if (clampedCursor > 0) {
296
+ saveUndo();
297
+ const start = Math.max(0, clampedCursor - count);
298
+ setYankBuf(value.slice(start, clampedCursor));
299
+ updateValue(value.slice(0, start) + value.slice(clampedCursor), start);
300
+ }
301
+ setCmdBuf('');
302
+ return;
303
+ }
304
+ if (cmd === 'dd') {
305
+ saveUndo();
306
+ setYankBuf(value);
307
+ updateValue('', 0);
308
+ setCmdBuf('');
309
+ return;
310
+ }
311
+ if (cmd === 'D') {
312
+ saveUndo();
313
+ setYankBuf(value.slice(clampedCursor));
314
+ updateValue(value.slice(0, clampedCursor), Math.max(0, clampedCursor - 1));
315
+ setCmdBuf('');
316
+ return;
317
+ }
318
+ if (cmd === 'C') { // change to end of line
319
+ saveUndo();
320
+ setYankBuf(value.slice(clampedCursor));
321
+ updateValue(value.slice(0, clampedCursor), clampedCursor);
322
+ switchMode('insert');
323
+ return;
324
+ }
325
+ if (cmd === 'dw') {
326
+ saveUndo();
327
+ let pos = clampedCursor;
328
+ for (let n = 0; n < count; n++)
329
+ pos = nextWord(value, pos);
330
+ setYankBuf(value.slice(clampedCursor, pos));
331
+ updateValue(value.slice(0, clampedCursor) + value.slice(pos), clampedCursor);
332
+ setCmdBuf('');
333
+ return;
334
+ }
335
+ if (cmd === 'db') {
336
+ saveUndo();
337
+ let pos = clampedCursor;
338
+ for (let n = 0; n < count; n++)
339
+ pos = prevWord(value, pos);
340
+ setYankBuf(value.slice(pos, clampedCursor));
341
+ updateValue(value.slice(0, pos) + value.slice(clampedCursor), pos);
342
+ setCmdBuf('');
343
+ return;
344
+ }
345
+ if (cmd === 'cw') { // change word
346
+ saveUndo();
347
+ let pos = clampedCursor;
348
+ for (let n = 0; n < count; n++)
349
+ pos = nextWord(value, pos);
350
+ setYankBuf(value.slice(clampedCursor, pos));
351
+ updateValue(value.slice(0, clampedCursor) + value.slice(pos), clampedCursor);
352
+ switchMode('insert');
353
+ return;
354
+ }
355
+ if (cmd === 'cb') { // change back
356
+ saveUndo();
357
+ let pos = clampedCursor;
358
+ for (let n = 0; n < count; n++)
359
+ pos = prevWord(value, pos);
360
+ setYankBuf(value.slice(pos, clampedCursor));
361
+ updateValue(value.slice(0, pos) + value.slice(clampedCursor), pos);
362
+ switchMode('insert');
363
+ return;
364
+ }
365
+ // ── Yank & Paste ──
366
+ if (cmd === 'yy') {
367
+ setYankBuf(value);
368
+ setCmdBuf('');
369
+ return;
370
+ }
371
+ if (cmd === 'yw') {
372
+ const pos = nextWord(value, clampedCursor);
373
+ setYankBuf(value.slice(clampedCursor, pos));
374
+ setCmdBuf('');
375
+ return;
376
+ }
377
+ if (cmd === 'p') {
378
+ if (yankBuf) {
379
+ saveUndo();
380
+ const insertAt = Math.min(clampedCursor + 1, value.length);
381
+ updateValue(value.slice(0, insertAt) + yankBuf + value.slice(insertAt), insertAt + yankBuf.length - 1);
382
+ }
383
+ setCmdBuf('');
384
+ return;
385
+ }
386
+ if (cmd === 'P') {
387
+ if (yankBuf) {
388
+ saveUndo();
389
+ updateValue(value.slice(0, clampedCursor) + yankBuf + value.slice(clampedCursor), clampedCursor + yankBuf.length - 1);
390
+ }
391
+ setCmdBuf('');
392
+ return;
393
+ }
394
+ // ── Undo ──
395
+ if (cmd === 'u') {
396
+ if (undoStack.length > 0) {
397
+ const prev = undoStack[undoStack.length - 1];
398
+ setUndoStack(s => s.slice(0, -1));
399
+ onChange(prev);
400
+ setCursor(Math.min(clampedCursor, Math.max(0, prev.length - 1)));
401
+ lastValueRef.current = prev;
402
+ }
403
+ setCmdBuf('');
404
+ return;
405
+ }
406
+ // ── Accumulate partial commands ──
407
+ // Valid prefixes for multi-key commands
408
+ if (/^\d+$/.test(fullCmd)) {
409
+ setCmdBuf(fullCmd);
410
+ return;
411
+ } // count accumulating
412
+ if (fullCmd === 'd' || fullCmd === 'c' || fullCmd === 'y') {
413
+ setCmdBuf(fullCmd);
414
+ return;
415
+ } // operator pending
416
+ if (/^\d+[dcy]$/.test(fullCmd)) {
417
+ setCmdBuf(fullCmd);
418
+ return;
419
+ } // count + operator
420
+ // Unknown command — reset
421
+ setCmdBuf('');
422
+ }, { isActive: focus });
423
+ // ── Render ──
424
+ const displayValue = value || (mode === 'insert' ? placeholder : '');
425
+ const isEmpty = !value;
426
+ // Build the displayed text with cursor
427
+ let rendered;
428
+ if (isEmpty && mode === 'insert') {
429
+ rendered = (_jsx(Text, { dimColor: true, children: placeholder }));
430
+ }
431
+ else {
432
+ // Split text around cursor for highlighting
433
+ const before = displayValue.slice(0, clampedCursor);
434
+ const atCursor = displayValue[clampedCursor] || ' ';
435
+ const after = displayValue.slice(clampedCursor + 1);
436
+ rendered = (_jsxs(Text, { children: [before, _jsx(Text, { inverse: focus, bold: mode === 'normal', children: atCursor }), after] }));
437
+ }
438
+ return (_jsxs(Box, { children: [showMode && mode === 'normal' && (_jsx(Text, { color: "yellow", bold: true, children: "[N] " })), rendered, cmdBuf && mode === 'normal' && (_jsxs(Text, { dimColor: true, children: [" ", cmdBuf] }))] }));
439
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
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": {