@ruvnet/open-claude-code 2.0.1 → 2.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/package.json +29 -4
  4. package/src/auth/oauth.mjs +46 -6
  5. package/src/config/cli-args.mjs +20 -0
  6. package/src/config/env.mjs +2 -2
  7. package/src/config/settings.mjs +30 -130
  8. package/src/core/agent-loop.mjs +92 -28
  9. package/src/core/providers.mjs +19 -1
  10. package/src/core/session.mjs +34 -149
  11. package/src/core/system-prompt.mjs +43 -47
  12. package/src/hooks/engine.mjs +10 -2
  13. package/src/index.mjs +60 -32
  14. package/src/optimize/cascade.mjs +128 -0
  15. package/src/optimize/commands.mjs +139 -0
  16. package/src/optimize/router.mjs +212 -0
  17. package/src/optimize/store.mjs +170 -0
  18. package/src/permissions/checker.mjs +8 -79
  19. package/src/permissions/injection-check.mjs +3 -2
  20. package/src/permissions/path-check.mjs +8 -0
  21. package/src/permissions/sandbox.mjs +8 -2
  22. package/src/permissions/ssrf-check.mjs +102 -0
  23. package/src/plugins/loader.mjs +14 -6
  24. package/src/skills/loader.mjs +16 -4
  25. package/src/tools/bash.mjs +3 -40
  26. package/src/tools/enter-worktree.mjs +5 -3
  27. package/src/tools/exit-worktree.mjs +4 -3
  28. package/src/tools/grep.mjs +26 -11
  29. package/src/tools/lsp.mjs +23 -9
  30. package/src/tools/read.mjs +20 -9
  31. package/src/tools/send-message.mjs +5 -1
  32. package/src/tools/web-fetch.mjs +15 -1
  33. package/src/tools/web-search.mjs +13 -1
  34. package/src/ui/app.mjs +286 -0
  35. package/src/ui/commands.mjs +121 -71
  36. package/src/ui/components.mjs +219 -0
  37. package/src/ui/ink-app.mjs +13 -52
  38. package/src/ui/markdown.mjs +278 -0
  39. package/src/ui/repl.mjs +10 -25
  40. package/test/test.mjs +0 -2657
@@ -1,22 +1,30 @@
1
- /** Slash Commands — all 39+ commands. Fully functional (no stubs). */
2
- import fs from 'fs';
3
- import path from 'path';
4
- import { execSync } from 'child_process';
1
+ /**
2
+ * Slash Commands — all 39 commands from Claude Code.
3
+ *
4
+ * Each command is a function(args, state) that returns a string response.
5
+ * Commands are invoked via /command-name in the REPL.
6
+ */
7
+
5
8
  import { SessionManager } from '../core/session.mjs';
6
9
  import { CheckpointManager } from '../core/checkpoints.mjs';
7
10
  import { PromptCache } from '../core/cache.mjs';
8
11
  import { readEnv, listEnvVars } from '../config/env.mjs';
9
- import { cronStore as _cronStore } from '../tools/cron-create.mjs';
12
+ import { readApiKey } from '../core/providers.mjs';
10
13
  import * as telemetry from '../telemetry/index.mjs';
14
+ import { OutcomeStore } from '../optimize/store.mjs';
11
15
 
12
16
  const checkpoints = new CheckpointManager();
13
17
  const promptCache = new PromptCache();
14
18
  let sessionManager = null;
19
+
15
20
  function getSession() {
16
21
  if (!sessionManager) sessionManager = new SessionManager();
17
22
  return sessionManager;
18
23
  }
19
24
 
25
+ /**
26
+ * All slash commands.
27
+ */
20
28
  export const COMMANDS = {
21
29
  '/help': {
22
30
  description: 'Show available commands',
@@ -25,6 +33,7 @@ export const COMMANDS = {
25
33
  for (const [name, cmd] of Object.entries(COMMANDS)) {
26
34
  lines.push(` ${name.padEnd(20)} ${cmd.description}`);
27
35
  }
36
+ lines.push('');
28
37
  return lines.join('\n');
29
38
  },
30
39
  },
@@ -67,13 +76,11 @@ export const COMMANDS = {
67
76
  description: 'Show token usage and estimated cost',
68
77
  handler(args, state) {
69
78
  const { input, output } = state.tokenUsage;
79
+ // Use model-appropriate pricing
70
80
  const model = state.model || '';
71
81
  let priceIn = 3, priceOut = 15; // Sonnet default
72
82
  if (model.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
73
83
  if (model.includes('opus')) { priceIn = 15; priceOut = 75; }
74
- if (model.includes('gpt-4o-mini')) { priceIn = 0.15; priceOut = 0.6; }
75
- else if (model.includes('gpt-4o')) { priceIn = 2.5; priceOut = 10; }
76
- if (model.includes('gemini')) { priceIn = 0.075; priceOut = 0.3; }
77
84
 
78
85
  const costIn = (input / 1_000_000) * priceIn;
79
86
  const costOut = (output / 1_000_000) * priceOut;
@@ -91,30 +98,25 @@ export const COMMANDS = {
91
98
  description: 'Check system health and configuration',
92
99
  handler(args, state) {
93
100
  const checks = [];
101
+ const anthropicKey = readApiKey('ANTHROPIC_API_KEY');
94
102
  checks.push(`Node.js: ${process.version}`);
95
- checks.push(`ANTHROPIC_API_KEY: ${process.env.ANTHROPIC_API_KEY ? 'set' : 'NOT SET'}`);
96
- checks.push(`OPENAI_API_KEY: ${process.env.OPENAI_API_KEY ? 'set' : 'NOT SET'}`);
97
- checks.push(`GOOGLE_API_KEY: ${process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY ? 'set' : 'NOT SET'}`);
103
+ checks.push(`ANTHROPIC_API_KEY: ${anthropicKey ? 'set' : 'NOT SET'}`);
98
104
  checks.push(`Model: ${state.model || 'default'}`);
99
105
  checks.push(`Tools: ${state.tools?.list?.()?.length || 0}`);
100
106
  checks.push(`Messages: ${state.messages.length}`);
101
107
  checks.push(`CWD: ${process.cwd()}`);
102
108
  checks.push(`Platform: ${process.platform}`);
103
109
 
104
- // API connectivity check (synchronous status)
105
- let apiStatus = 'no key';
106
- if (process.env.ANTHROPIC_API_KEY) apiStatus = 'key present';
107
- if (process.env.OPENAI_API_KEY) checks.push(`OpenAI API: key present`);
108
- if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) checks.push(`Google API: key present`);
110
+ // Check API connectivity
111
+ let apiStatus = 'unchecked';
112
+ if (anthropicKey) {
113
+ apiStatus = 'key present';
114
+ }
109
115
  checks.push(`API: ${apiStatus}`);
110
116
 
111
117
  // Check MCP servers
112
- const mcpClients = state._mcpClients || [];
113
- checks.push(`MCP servers: ${mcpClients.length}`);
114
- for (const client of mcpClients) {
115
- const status = client.connected ? 'connected' : 'disconnected';
116
- checks.push(` - ${client.config?.command || 'unknown'}: ${status}`);
117
- }
118
+ const mcpCount = state._mcpClients?.length || 0;
119
+ checks.push(`MCP servers: ${mcpCount}`);
118
120
 
119
121
  return `System check:\n${checks.map(c => ` ${c}`).join('\n')}`;
120
122
  },
@@ -136,9 +138,8 @@ export const COMMANDS = {
136
138
  description: 'Show or switch model',
137
139
  handler(args, state) {
138
140
  if (args) {
139
- const old = state.model;
140
141
  state.model = args;
141
- return `Model switched: ${old} -> ${args}`;
142
+ return `Model switched to: ${args}`;
142
143
  }
143
144
  return `Current model: ${state.model || 'default'}`;
144
145
  },
@@ -168,14 +169,28 @@ export const COMMANDS = {
168
169
  },
169
170
  },
170
171
 
171
- '/quit': { description: 'Exit the REPL', handler() { return 'EXIT'; } },
172
- '/exit': { description: 'Exit the REPL', handler() { return 'EXIT'; } },
173
- '/bug': { description: 'Report a bug', handler() { return 'Report bugs at: https://github.com/ruvnet/open-claude-code/issues'; } },
172
+ '/quit': {
173
+ description: 'Exit the REPL',
174
+ handler() { return 'EXIT'; },
175
+ },
176
+
177
+ '/exit': {
178
+ description: 'Exit the REPL',
179
+ handler() { return 'EXIT'; },
180
+ },
181
+
182
+ '/bug': {
183
+ description: 'Report a bug',
184
+ handler() {
185
+ return 'Report bugs at: https://github.com/ruvnet/open-claude-code/issues';
186
+ },
187
+ },
174
188
 
175
189
  '/review': {
176
190
  description: 'Review recent changes',
177
191
  handler(args, state) {
178
192
  try {
193
+ const { execSync } = require('child_process');
179
194
  const diff = execSync('git diff --stat HEAD~1 2>/dev/null || echo "No git history"', { encoding: 'utf-8' });
180
195
  return `Recent changes:\n${diff}`;
181
196
  } catch {
@@ -187,6 +202,8 @@ export const COMMANDS = {
187
202
  '/init': {
188
203
  description: 'Initialize Claude Code in current directory',
189
204
  handler() {
205
+ const fs = require('fs');
206
+ const path = require('path');
190
207
  const claudeDir = path.join(process.cwd(), '.claude');
191
208
  fs.mkdirSync(claudeDir, { recursive: true });
192
209
  const settingsFile = path.join(claudeDir, 'settings.json');
@@ -197,11 +214,25 @@ export const COMMANDS = {
197
214
  },
198
215
  },
199
216
 
200
- '/login': { description: 'Set API key', handler(args) {
201
- if (args) { process.env.ANTHROPIC_API_KEY = args; return 'API key set.'; }
202
- return 'Usage: /login <api-key>';
203
- }},
204
- '/logout': { description: 'Clear API key', handler() { delete process.env.ANTHROPIC_API_KEY; return 'API key cleared.'; } },
217
+ '/login': {
218
+ description: 'Set API key',
219
+ handler(args) {
220
+ const key = (args || '').trim();
221
+ if (key) {
222
+ process.env.ANTHROPIC_API_KEY = key;
223
+ return 'API key set.';
224
+ }
225
+ return 'Usage: /login <api-key>';
226
+ },
227
+ },
228
+
229
+ '/logout': {
230
+ description: 'Clear API key',
231
+ handler() {
232
+ delete process.env.ANTHROPIC_API_KEY;
233
+ return 'API key cleared.';
234
+ },
235
+ },
205
236
 
206
237
  '/status': {
207
238
  description: 'Show session status',
@@ -359,9 +390,10 @@ export const COMMANDS = {
359
390
  '/schedule': {
360
391
  description: 'List scheduled tasks',
361
392
  handler() {
362
- if (!_cronStore || _cronStore.size === 0) return 'No scheduled tasks.';
393
+ const { cronStore } = require('../tools/cron-create.mjs');
394
+ if (!cronStore || cronStore.size === 0) return 'No scheduled tasks.';
363
395
  const lines = [];
364
- for (const [, job] of _cronStore) {
396
+ for (const [, job] of cronStore) {
365
397
  lines.push(` ${job.id}: ${job.name} (${job.schedule})`);
366
398
  }
367
399
  return `Scheduled:\n${lines.join('\n')}`;
@@ -395,9 +427,8 @@ export const COMMANDS = {
395
427
  description: 'Show git diff',
396
428
  handler() {
397
429
  try {
398
- // execSync imported at top
399
- const d = execSync('git diff --stat 2>/dev/null || echo "Not in a git repo"', { encoding: 'utf-8' });
400
- return d.trim() || 'No changes.';
430
+ const { execSync } = require('child_process');
431
+ return execSync('git diff --stat 2>/dev/null || echo "Not in a git repo"', { encoding: 'utf-8' });
401
432
  } catch {
402
433
  return 'Unable to show diff.';
403
434
  }
@@ -416,10 +447,13 @@ export const COMMANDS = {
416
447
  description: 'Create a git commit with AI message',
417
448
  handler(args) {
418
449
  try {
419
- // execSync imported at top
450
+ // Security: use execFileSync with discrete args so the commit
451
+ // message is never shell-interpolated (prevents injection via
452
+ // crafted message strings containing shell metacharacters).
453
+ const { execFileSync } = require('child_process');
420
454
  const msg = args || 'Update from open-claude-code';
421
- execSync('git add -A', { encoding: 'utf-8' });
422
- execSync(`git commit -m "${msg}"`, { encoding: 'utf-8' });
455
+ execFileSync('git', ['add', '-A'], { encoding: 'utf-8' });
456
+ execFileSync('git', ['commit', '-m', msg], { encoding: 'utf-8' });
423
457
  return `Committed: ${msg}`;
424
458
  } catch (err) {
425
459
  return `Commit failed: ${err.message}`;
@@ -427,45 +461,57 @@ export const COMMANDS = {
427
461
  },
428
462
  },
429
463
 
430
- '/pr': { description: 'Create a pull request', handler() { return 'PR creation requires gh CLI. Run: gh pr create --fill'; } },
431
- '/release': { description: 'Create a release', handler() { return 'Release creation requires gh CLI. Run: gh release create <tag>'; } },
464
+ '/pr': {
465
+ description: 'Create a pull request (stub)',
466
+ handler() {
467
+ return 'PR creation requires gh CLI. Run: gh pr create --fill';
468
+ },
469
+ },
432
470
 
433
- '/resume': {
434
- description: 'Resume a saved session',
435
- handler(args, state) {
436
- const session = getSession();
437
- if (args) {
438
- // Resume specific session by ID
439
- const ok = session.resume(state, args.trim());
440
- if (ok) {
441
- return `Resumed session: ${session.sessionId} (${state.messages.length} messages, ${state.turnCount} turns)`;
442
- }
443
- return `Could not find session: ${args.trim()}`;
444
- }
445
- // Resume most recent
446
- const ok = session.resume(state);
447
- if (ok) {
448
- return `Resumed session: ${session.sessionId} (${state.messages.length} messages, ${state.turnCount} turns)`;
449
- }
450
- return 'No resumable session found.';
471
+ '/release': {
472
+ description: 'Create a release (stub)',
473
+ handler() {
474
+ return 'Release creation requires gh CLI. Run: gh release create <tag>';
451
475
  },
452
476
  },
453
477
 
454
- '/sessions': {
455
- description: 'List recent sessions',
478
+ '/optimize': {
479
+ description: 'Self-optimization router status / report (metaharness)',
456
480
  handler(args, state) {
457
- const session = getSession();
458
- const list = session.listSessions(10);
459
- if (list.length === 0) return 'No saved sessions.';
460
- const lines = list.map(s =>
461
- ` ${s.id} | ${s.savedAt || '?'} | ${s.turnCount || 0} turns | ${s.messageCount || 0} msgs`
462
- );
463
- return `Recent sessions:\n${lines.join('\n')}`;
481
+ const sub = (args || '').trim().split(/\s+/)[0] || 'status';
482
+ const store = new OutcomeStore();
483
+ if (sub === 'report') {
484
+ const summary = store.summary();
485
+ if (summary.total === 0) return 'No recorded outcomes yet. Start occ with --self-optimize to collect real data.';
486
+ const lines = [`Optimization report — ${summary.total} outcomes`];
487
+ for (const [m, b] of Object.entries(summary.byModel)) {
488
+ lines.push(` ${m}: ${b.attempts} runs, ${(b.successRate * 100).toFixed(0)}% ok, ~$${b.costUsd.toFixed(4)}`);
489
+ }
490
+ lines.push(` total est cost: $${summary.totalCostUsd.toFixed(4)}`);
491
+ return lines.join('\n');
492
+ }
493
+ if (sub === 'reset') {
494
+ return store.reset() ? 'Outcome store cleared.' : 'Nothing to reset.';
495
+ }
496
+ // status (default)
497
+ const enabled = !!(state && state._cascade);
498
+ const summary = store.summary();
499
+ return [
500
+ 'MetaHarness self-optimization',
501
+ ` live router this session: ${enabled ? 'ON' : 'OFF (start with --self-optimize)'}`,
502
+ ` recorded outcomes: ${summary.total}`,
503
+ ` store: ${store.filePath}`,
504
+ ].join('\n');
464
505
  },
465
506
  },
466
507
  };
467
508
 
468
- /** Execute a slash command. */
509
+ /**
510
+ * Execute a slash command.
511
+ * @param {string} input - full command string (e.g., "/model claude-sonnet-4-6")
512
+ * @param {object} state - agent loop state
513
+ * @returns {{ response: string, exit: boolean }}
514
+ */
469
515
  export function executeCommand(input, state) {
470
516
  const parts = input.split(/\s+/);
471
517
  const cmd = parts[0].toLowerCase();
@@ -480,7 +526,11 @@ export function executeCommand(input, state) {
480
526
  return { response, exit: response === 'EXIT' };
481
527
  }
482
528
 
483
- /** Get command completions for tab-complete. */
529
+ /**
530
+ * Get command completions for tab-complete.
531
+ * @param {string} partial
532
+ * @returns {string[]}
533
+ */
484
534
  export function getCompletions(partial) {
485
535
  return Object.keys(COMMANDS).filter(c => c.startsWith(partial));
486
536
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Ink UI Components — React components for the terminal interface.
3
+ *
4
+ * Uses React.createElement (no JSX transpiler needed).
5
+ *
6
+ * Components:
7
+ * - StatusBar: persistent bottom bar (model, context%, cost, time, mode)
8
+ * - Message: renders different message types
9
+ * - AssistantMessage: markdown-formatted assistant output
10
+ * - UserMessage: user input display
11
+ * - ToolMessage: tool execution with spinner
12
+ * - ThinkingMessage: dim italic thinking blocks
13
+ * - CodeBlock: bordered code display
14
+ * - PermissionPrompt: interactive y/n/a prompt
15
+ * - LoadingIndicator: spinner during API calls
16
+ * - WelcomeBanner: startup banner
17
+ */
18
+
19
+ import React, { useState, useEffect } from 'react';
20
+ import { Box, Text } from 'ink';
21
+ import Spinner from 'ink-spinner';
22
+ import { renderMarkdown } from './markdown.mjs';
23
+
24
+ const h = React.createElement;
25
+
26
+ /**
27
+ * Format duration in seconds to human-readable string.
28
+ */
29
+ function formatDuration(seconds) {
30
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
31
+ const mins = Math.floor(seconds / 60);
32
+ const secs = Math.floor(seconds % 60);
33
+ return `${mins}m${secs}s`;
34
+ }
35
+
36
+ /**
37
+ * Truncate text to a maximum length.
38
+ */
39
+ function truncate(text, max) {
40
+ if (!text) return '';
41
+ if (text.length <= max) return text;
42
+ return text.slice(0, max) + '...';
43
+ }
44
+
45
+ /**
46
+ * Format cost to display string.
47
+ */
48
+ function formatCost(cost) {
49
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
50
+ return `$${cost.toFixed(2)}`;
51
+ }
52
+
53
+ // ---- Status Bar ----
54
+
55
+ export function StatusBar({ model, tokens, cost, mode, startTime, contextMax }) {
56
+ const [elapsed, setElapsed] = useState(0);
57
+
58
+ useEffect(() => {
59
+ const interval = setInterval(() => {
60
+ setElapsed(Math.floor((Date.now() - (startTime || Date.now())) / 1000));
61
+ }, 1000);
62
+ return () => clearInterval(interval);
63
+ }, [startTime]);
64
+
65
+ const totalTokens = (tokens?.input || 0) + (tokens?.output || 0);
66
+ const maxCtx = contextMax || 200000;
67
+ const contextPct = Math.min(100, Math.round((totalTokens / maxCtx) * 100));
68
+ const ctxColor = contextPct > 80 ? 'red' : contextPct > 50 ? 'yellow' : 'green';
69
+
70
+ return h(Box, null,
71
+ h(Text, { color: 'cyan', bold: true }, '\u258A '),
72
+ h(Text, { color: 'white' }, 'open-claude-code'),
73
+ h(Text, { color: 'gray' }, ' \u2502 '),
74
+ h(Text, { color: 'green' }, model || 'default'),
75
+ h(Text, { color: 'gray' }, ' \u2502 '),
76
+ h(Text, { color: 'yellow' }, '\u23F1 ', formatDuration(elapsed)),
77
+ h(Text, { color: 'gray' }, ' \u2502 '),
78
+ h(Text, { color: ctxColor }, '\u25CF ', contextPct, '% ctx'),
79
+ h(Text, { color: 'gray' }, ' \u2502 '),
80
+ h(Text, { color: 'white' }, formatCost(cost || 0)),
81
+ h(Text, { color: 'gray' }, ' \u2502 '),
82
+ h(Text, { color: 'magenta' }, mode || 'default'),
83
+ );
84
+ }
85
+
86
+ // ---- Message Components ----
87
+
88
+ export function Message({ role, content, toolName, toolResult, toolStatus, thinking }) {
89
+ if (role === 'assistant') return h(AssistantMessage, { content });
90
+ if (role === 'tool') return h(ToolMessage, { name: toolName, result: toolResult, status: toolStatus });
91
+ if (role === 'thinking') return h(ThinkingMessage, { content: thinking });
92
+ if (role === 'user') return h(UserMessage, { content });
93
+ if (role === 'error') return h(ErrorMessage, { content });
94
+ if (role === 'system') return h(SystemMessage, { content });
95
+ return null;
96
+ }
97
+
98
+ export function AssistantMessage({ content }) {
99
+ if (!content) return null;
100
+ const rendered = renderMarkdown(content);
101
+ return h(Box, { marginLeft: 0, marginBottom: 0 },
102
+ h(Text, null, rendered),
103
+ );
104
+ }
105
+
106
+ export function UserMessage({ content }) {
107
+ if (!content) return null;
108
+ return h(Box, { marginBottom: 0 },
109
+ h(Text, { color: 'blue', bold: true }, 'You: '),
110
+ h(Text, null, content),
111
+ );
112
+ }
113
+
114
+ export function ToolMessage({ name, result, status }) {
115
+ const children = [
116
+ h(Text, { color: 'yellow', key: 'label' }, '[', name, '] '),
117
+ ];
118
+
119
+ if (status === 'running') {
120
+ children.push(
121
+ h(Text, { color: 'cyan', key: 'spinner' },
122
+ h(Spinner, { type: 'dots' }),
123
+ ' running...',
124
+ ),
125
+ );
126
+ }
127
+ if (status === 'done' && result) {
128
+ children.push(
129
+ h(Text, { color: 'gray', key: 'result' }, ' ', truncate(String(result), 200)),
130
+ );
131
+ }
132
+ if (status === 'error' && result) {
133
+ children.push(
134
+ h(Text, { color: 'red', key: 'error' }, ' ', truncate(String(result), 200)),
135
+ );
136
+ }
137
+
138
+ return h(Box, null, ...children);
139
+ }
140
+
141
+ export function ThinkingMessage({ content }) {
142
+ if (!content) return null;
143
+ return h(Box, null,
144
+ h(Text, { dimColor: true, italic: true }, '\uD83D\uDCAD ', truncate(content, 500)),
145
+ );
146
+ }
147
+
148
+ export function ErrorMessage({ content }) {
149
+ return h(Box, null,
150
+ h(Text, { color: 'red', bold: true }, 'Error: '),
151
+ h(Text, { color: 'red' }, content),
152
+ );
153
+ }
154
+
155
+ export function SystemMessage({ content }) {
156
+ return h(Box, null,
157
+ h(Text, { dimColor: true }, content),
158
+ );
159
+ }
160
+
161
+ // ---- Code Block ----
162
+
163
+ export function CodeBlock({ code, language }) {
164
+ if (!code) return null;
165
+ const children = [];
166
+ if (language) {
167
+ children.push(h(Text, { dimColor: true, key: 'lang' }, language));
168
+ }
169
+ children.push(h(Text, { key: 'code' }, code));
170
+
171
+ return h(Box, {
172
+ borderStyle: 'single',
173
+ borderColor: 'gray',
174
+ paddingX: 1,
175
+ marginLeft: 2,
176
+ }, ...children);
177
+ }
178
+
179
+ // ---- Permission Prompt ----
180
+
181
+ export function PermissionPrompt({ toolName, command }) {
182
+ const children = [
183
+ h(Text, { color: 'yellow', key: 'allow' }, 'Allow '),
184
+ h(Text, { bold: true, key: 'tool' }, toolName),
185
+ ];
186
+ if (command) {
187
+ children.push(
188
+ h(Text, { color: 'gray', key: 'cmd' }, ': ', truncate(command, 60)),
189
+ );
190
+ }
191
+ children.push(h(Text, { key: 'prompt' }, ' [y/n/a] '));
192
+
193
+ return h(Box, {
194
+ borderStyle: 'round',
195
+ borderColor: 'yellow',
196
+ paddingX: 1,
197
+ }, ...children);
198
+ }
199
+
200
+ // ---- Loading Indicator ----
201
+
202
+ export function LoadingIndicator({ tool }) {
203
+ return h(Box, null,
204
+ h(Text, { color: 'cyan' },
205
+ h(Spinner, { type: 'dots' }),
206
+ ),
207
+ h(Text, { dimColor: true }, ' ', tool ? `Running ${tool}...` : 'Thinking...'),
208
+ );
209
+ }
210
+
211
+ // ---- Welcome Banner ----
212
+
213
+ export function WelcomeBanner({ model, toolCount }) {
214
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
215
+ h(Text, { bold: true }, 'open-claude-code v2'),
216
+ h(Text, { dimColor: true }, 'Model: ', model || 'default', ' | Tools: ', toolCount || 0),
217
+ h(Text, { dimColor: true }, 'Type your prompt or /help. Press Ctrl+C to exit.'),
218
+ );
219
+ }
@@ -8,7 +8,6 @@
8
8
  * - Markdown bold, italic, inline code rendering
9
9
  * - Status line showing model, tokens used, cost estimate
10
10
  * - Progress for tool execution (show tool name + "running...")
11
- * - Cost display after each turn: (tokens: X in / Y out | $Z)
12
11
  */
13
12
 
14
13
  // ANSI color codes
@@ -30,7 +29,6 @@ const COLORS = {
30
29
  bgGreen: '\x1b[42m',
31
30
  bgYellow: '\x1b[43m',
32
31
  bgBlue: '\x1b[44m',
33
- bgGray: '\x1b[48;5;236m',
34
32
  };
35
33
 
36
34
  const noColor = process.env.NO_COLOR === '1';
@@ -80,7 +78,6 @@ export class Spinner {
80
78
 
81
79
  /**
82
80
  * Highlight code blocks in markdown text.
83
- * Detects ``` blocks and adds subtle background/syntax coloring.
84
81
  * @param {string} text
85
82
  * @returns {string}
86
83
  */
@@ -91,7 +88,7 @@ export function highlightCode(text) {
91
88
  const header = lang ? c('dim', ` ${lang}`) : '';
92
89
  const highlighted = code
93
90
  .split('\n')
94
- .map(line => `${COLORS.bgGray} ${highlightLine(line, lang)} ${COLORS.reset}`)
91
+ .map(line => ` ${highlightLine(line, lang)}`)
95
92
  .join('\n');
96
93
  return `${c('dim', '```')}${header}\n${highlighted}\n${c('dim', '```')}`;
97
94
  });
@@ -141,18 +138,16 @@ export function renderMarkdown(text) {
141
138
 
142
139
  /**
143
140
  * Render a tool progress indicator.
144
- * Shows [ToolName] running... in yellow.
145
141
  * @param {string} toolName
146
142
  * @param {string} status
147
143
  */
148
144
  export function renderToolProgress(toolName, status) {
149
145
  const icon = status === 'running' ? c('yellow', '>>') : c('green', '>>');
150
- return `${icon} ${c('yellow', `[${toolName}]`)} ${c('dim', status)}`;
146
+ return `${icon} ${c('yellow', toolName)} ${c('dim', status)}`;
151
147
  }
152
148
 
153
149
  /**
154
150
  * Render a status bar with token counts and cost estimate.
155
- * Shown after each turn: (tokens: X in / Y out | $Z)
156
151
  * @param {object} state
157
152
  */
158
153
  export function renderStatusBar(state) {
@@ -161,59 +156,25 @@ export function renderStatusBar(state) {
161
156
  const model = state.model || 'default';
162
157
  const inp = state.tokenUsage?.input || 0;
163
158
  const out = state.tokenUsage?.output || 0;
159
+ const tokens = `in:${inp} out:${out}`;
160
+ const turns = `turn:${state.turnCount || 0}`;
164
161
 
165
- // Cost estimate based on model
166
- let priceIn = 3, priceOut = 15; // Sonnet default
167
- if (model.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
168
- if (model.includes('opus')) { priceIn = 15; priceOut = 75; }
169
- if (model.includes('gpt-4o-mini')) { priceIn = 0.15; priceOut = 0.6; }
170
- else if (model.includes('gpt-4o')) { priceIn = 2.5; priceOut = 10; }
171
- if (model.includes('gemini')) { priceIn = 0.075; priceOut = 0.3; }
172
-
173
- const costIn = (inp / 1_000_000) * priceIn;
174
- const costOut = (out / 1_000_000) * priceOut;
162
+ // Cost estimate (Sonnet pricing by default)
163
+ const costIn = (inp / 1_000_000) * 3;
164
+ const costOut = (out / 1_000_000) * 15;
175
165
  const cost = `$${(costIn + costOut).toFixed(4)}`;
176
- const turns = `turn:${state.turnCount || 0}`;
177
166
 
178
- const costDisplay = `(tokens: ${formatNum(inp)} in / ${formatNum(out)} out | ${cost})`;
179
- const right = `${model} | ${costDisplay} | ${turns}`;
167
+ const right = `${model} | ${tokens} | ${cost} | ${turns}`;
180
168
  const padding = Math.max(0, cols - right.length - 1);
181
169
  return c('dim', `${' '.repeat(padding)}${right}`);
182
170
  }
183
171
 
184
172
  /**
185
- * Format a cost display for inline use.
186
- * @param {object} state
187
- * @returns {string}
188
- */
189
- export function renderCostInline(state) {
190
- const inp = state.tokenUsage?.input || 0;
191
- const out = state.tokenUsage?.output || 0;
192
- const model = state.model || '';
193
- let priceIn = 3, priceOut = 15;
194
- if (model.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
195
- if (model.includes('opus')) { priceIn = 15; priceOut = 75; }
196
- const cost = ((inp / 1_000_000) * priceIn) + ((out / 1_000_000) * priceOut);
197
- return c('dim', `(tokens: ${formatNum(inp)} in / ${formatNum(out)} out | $${cost.toFixed(4)})`);
198
- }
199
-
200
- function formatNum(n) {
201
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
202
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
203
- return String(n);
204
- }
205
-
206
- /**
207
- * Render an error message with context.
173
+ * Render an error message.
208
174
  * @param {string} message
209
- * @param {string} [context]
210
175
  */
211
- export function renderError(message, context) {
212
- let result = `${c('red', 'Error:')} ${message}`;
213
- if (context) {
214
- result += `\n${c('dim', ` Context: ${context}`)}`;
215
- }
216
- return result;
176
+ export function renderError(message) {
177
+ return `${c('red', 'Error:')} ${message}`;
217
178
  }
218
179
 
219
180
  /**
@@ -233,11 +194,11 @@ export function renderSuccess(message) {
233
194
  }
234
195
 
235
196
  /**
236
- * Render thinking output (dim/gray).
197
+ * Render thinking output (dim).
237
198
  * @param {string} text
238
199
  */
239
200
  export function renderThinking(text) {
240
- return c('gray', text);
201
+ return c('dim', text);
241
202
  }
242
203
 
243
204
  /**