@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.
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/package.json +29 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -2
- package/src/config/settings.mjs +30 -130
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +34 -149
- package/src/core/system-prompt.mjs +43 -47
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +60 -32
- package/src/optimize/cascade.mjs +128 -0
- package/src/optimize/commands.mjs +139 -0
- package/src/optimize/router.mjs +212 -0
- package/src/optimize/store.mjs +170 -0
- package/src/permissions/checker.mjs +8 -79
- package/src/permissions/injection-check.mjs +3 -2
- package/src/permissions/path-check.mjs +8 -0
- package/src/permissions/sandbox.mjs +8 -2
- package/src/permissions/ssrf-check.mjs +102 -0
- package/src/plugins/loader.mjs +14 -6
- package/src/skills/loader.mjs +16 -4
- package/src/tools/bash.mjs +3 -40
- package/src/tools/enter-worktree.mjs +5 -3
- package/src/tools/exit-worktree.mjs +4 -3
- package/src/tools/grep.mjs +26 -11
- package/src/tools/lsp.mjs +23 -9
- package/src/tools/read.mjs +20 -9
- package/src/tools/send-message.mjs +5 -1
- package/src/tools/web-fetch.mjs +15 -1
- package/src/tools/web-search.mjs +13 -1
- package/src/ui/app.mjs +286 -0
- package/src/ui/commands.mjs +121 -71
- package/src/ui/components.mjs +219 -0
- package/src/ui/ink-app.mjs +13 -52
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/repl.mjs +10 -25
- package/test/test.mjs +0 -2657
package/src/ui/commands.mjs
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
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: ${
|
|
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
|
|
105
|
-
let apiStatus = '
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
113
|
-
checks.push(`MCP servers: ${
|
|
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: ${
|
|
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': {
|
|
172
|
-
|
|
173
|
-
|
|
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': {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
422
|
-
|
|
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': {
|
|
431
|
-
|
|
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
|
-
'/
|
|
434
|
-
description: '
|
|
435
|
-
handler(
|
|
436
|
-
|
|
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
|
-
'/
|
|
455
|
-
description: '
|
|
478
|
+
'/optimize': {
|
|
479
|
+
description: 'Self-optimization router status / report (metaharness)',
|
|
456
480
|
handler(args, state) {
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/src/ui/ink-app.mjs
CHANGED
|
@@ -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 =>
|
|
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',
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
212
|
-
|
|
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
|
|
197
|
+
* Render thinking output (dim).
|
|
237
198
|
* @param {string} text
|
|
238
199
|
*/
|
|
239
200
|
export function renderThinking(text) {
|
|
240
|
-
return c('
|
|
201
|
+
return c('dim', text);
|
|
241
202
|
}
|
|
242
203
|
|
|
243
204
|
/**
|