@ruvnet/open-claude-code 2.0.0 → 2.0.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/package.json +1 -1
- package/src/config/env.mjs +2 -0
- package/src/config/settings.mjs +130 -24
- package/src/core/session.mjs +145 -4
- package/src/core/system-prompt.mjs +47 -43
- package/src/index.mjs +30 -8
- package/src/permissions/checker.mjs +79 -8
- package/src/tools/bash.mjs +40 -3
- package/src/ui/commands.mjs +70 -83
- package/src/ui/ink-app.mjs +52 -13
- package/src/ui/repl.mjs +25 -10
- package/test/test.mjs +800 -0
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Integrates with prompt system for interactive permission in default mode,
|
|
5
5
|
* injection checking for Bash commands, and path validation for file ops.
|
|
6
|
+
* Caches "always allow" decisions per tool per session.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { requiresPermission } from './prompt.mjs';
|
|
9
|
+
import { requiresPermission, promptPermission, formatToolSummary } from './prompt.mjs';
|
|
9
10
|
import { checkInjection } from './injection-check.mjs';
|
|
10
11
|
import { validatePath } from './path-check.mjs';
|
|
11
12
|
|
|
@@ -13,8 +14,18 @@ export function createPermissionChecker(config = {}) {
|
|
|
13
14
|
const mode = config.defaultMode || process.env.CLAUDE_CODE_PERMISSION_MODE || 'default';
|
|
14
15
|
const rl = config.rl || null; // readline interface for prompts
|
|
15
16
|
|
|
17
|
+
// Cache for "always allow" decisions: tool -> true
|
|
18
|
+
const alwaysAllowed = new Set();
|
|
19
|
+
|
|
16
20
|
return {
|
|
17
21
|
mode,
|
|
22
|
+
alwaysAllowed,
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a tool call is permitted.
|
|
26
|
+
* In default mode with readline, prompts the user interactively.
|
|
27
|
+
* Caches "always allow" decisions per tool per session.
|
|
28
|
+
*/
|
|
18
29
|
async check(toolName, input) {
|
|
19
30
|
// Always run injection check on Bash commands
|
|
20
31
|
if (toolName === 'Bash' && input?.command) {
|
|
@@ -35,23 +46,83 @@ export function createPermissionChecker(config = {}) {
|
|
|
35
46
|
switch (mode) {
|
|
36
47
|
case 'bypassPermissions': return true;
|
|
37
48
|
case 'acceptEdits':
|
|
38
|
-
// Allow file ops,
|
|
49
|
+
// Allow file ops, prompt for Bash/Agent
|
|
50
|
+
if (['Edit', 'Write', 'MultiEdit', 'Read', 'Glob', 'Grep'].includes(toolName)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
39
53
|
if (toolName === 'Bash' || toolName === 'Agent') {
|
|
40
|
-
|
|
54
|
+
if (config.bypassBash) return true;
|
|
55
|
+
if (alwaysAllowed.has(toolName)) return true;
|
|
56
|
+
if (rl) return this._promptUser(toolName, input);
|
|
57
|
+
return false;
|
|
41
58
|
}
|
|
42
59
|
return true;
|
|
43
60
|
case 'auto': return true; // AI decides
|
|
44
61
|
case 'dontAsk': return false; // deny everything not pre-approved
|
|
45
|
-
case 'plan':
|
|
62
|
+
case 'plan':
|
|
63
|
+
return ['Read', 'Glob', 'Grep', 'LS', 'ToolSearch'].includes(toolName);
|
|
46
64
|
case 'default':
|
|
47
65
|
default:
|
|
48
|
-
//
|
|
66
|
+
// Safe tools always pass
|
|
49
67
|
if (!requiresPermission(toolName)) return true;
|
|
50
|
-
//
|
|
68
|
+
// Check cached "always allow"
|
|
69
|
+
if (alwaysAllowed.has(toolName)) return true;
|
|
70
|
+
// Without readline, allow (headless/print mode)
|
|
51
71
|
if (!rl) return true;
|
|
52
|
-
//
|
|
53
|
-
return
|
|
72
|
+
// Interactive prompt
|
|
73
|
+
return this._promptUser(toolName, input);
|
|
54
74
|
}
|
|
55
75
|
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Prompt user for permission, with options: y/n/a (always).
|
|
79
|
+
* @param {string} toolName
|
|
80
|
+
* @param {object} input
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
82
|
+
*/
|
|
83
|
+
async _promptUser(toolName, input) {
|
|
84
|
+
if (!rl || typeof rl.question !== 'function') return false;
|
|
85
|
+
|
|
86
|
+
const summary = formatToolSummary(toolName, input);
|
|
87
|
+
return new Promise(resolve => {
|
|
88
|
+
rl.question(`\x1b[33mAllow ${summary}?\x1b[0m [y/n/a(lways)] `, answer => {
|
|
89
|
+
const a = answer.trim().toLowerCase();
|
|
90
|
+
if (a === 'a' || a === 'always') {
|
|
91
|
+
alwaysAllowed.add(toolName);
|
|
92
|
+
resolve(true);
|
|
93
|
+
} else if (a === 'y' || a === 'yes') {
|
|
94
|
+
resolve(true);
|
|
95
|
+
} else {
|
|
96
|
+
resolve(false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Grant "always allow" for a tool without prompting.
|
|
104
|
+
* @param {string} toolName
|
|
105
|
+
*/
|
|
106
|
+
allowAlways(toolName) {
|
|
107
|
+
alwaysAllowed.add(toolName);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reset cached allow decisions.
|
|
112
|
+
*/
|
|
113
|
+
resetAllowed() {
|
|
114
|
+
alwaysAllowed.clear();
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get info about current permission state.
|
|
119
|
+
*/
|
|
120
|
+
getInfo() {
|
|
121
|
+
return {
|
|
122
|
+
mode,
|
|
123
|
+
alwaysAllowed: [...alwaysAllowed],
|
|
124
|
+
hasReadline: !!rl,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
56
127
|
};
|
|
57
128
|
}
|
package/src/tools/bash.mjs
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* - description parameter
|
|
8
8
|
* - 1MB output limit
|
|
9
9
|
* - ANSI code stripping by default
|
|
10
|
+
* - Sandbox integration (bubblewrap/seatbelt)
|
|
10
11
|
*/
|
|
11
12
|
import { spawn } from 'child_process';
|
|
13
|
+
import { Sandbox } from '../permissions/sandbox.mjs';
|
|
12
14
|
|
|
13
15
|
// Strip ANSI escape sequences
|
|
14
16
|
function stripAnsi(str) {
|
|
@@ -18,6 +20,40 @@ function stripAnsi(str) {
|
|
|
18
20
|
|
|
19
21
|
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB
|
|
20
22
|
|
|
23
|
+
// Sandbox instance — reused across calls
|
|
24
|
+
const sandbox = new Sandbox();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determine if a command should be sandboxed.
|
|
28
|
+
* @param {string} command
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
function shouldSandbox(command) {
|
|
32
|
+
// Forced sandbox via env var
|
|
33
|
+
if (process.env.CLAUDE_CODE_FORCE_SANDBOX === '1') return true;
|
|
34
|
+
// Bubblewrap env var
|
|
35
|
+
if (process.env.CLAUDE_CODE_BUBBLEWRAP === '1') return true;
|
|
36
|
+
// Sandbox setting
|
|
37
|
+
if (process.env.CLAUDE_CODE_SANDBOX === '1') return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wrap a command with sandbox if needed.
|
|
43
|
+
* @param {string} command
|
|
44
|
+
* @returns {string} possibly-wrapped command
|
|
45
|
+
*/
|
|
46
|
+
function applySandbox(command) {
|
|
47
|
+
if (!shouldSandbox(command)) return command;
|
|
48
|
+
|
|
49
|
+
const check = sandbox.check();
|
|
50
|
+
if (!check.available) return command; // graceful fallback
|
|
51
|
+
|
|
52
|
+
return sandbox.wrapCommand(command, {
|
|
53
|
+
allowWrite: [process.cwd(), '/tmp'],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
export const BashTool = {
|
|
22
58
|
name: 'Bash',
|
|
23
59
|
description: 'Execute a bash command and return its output.',
|
|
@@ -38,9 +74,10 @@ export const BashTool = {
|
|
|
38
74
|
},
|
|
39
75
|
async call(input) {
|
|
40
76
|
const timeout = Math.min(input.timeout || 120000, 600000);
|
|
77
|
+
const command = applySandbox(input.command);
|
|
41
78
|
|
|
42
79
|
if (input.run_in_background) {
|
|
43
|
-
return runBackground(
|
|
80
|
+
return runBackground(command);
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
return new Promise((resolve) => {
|
|
@@ -49,7 +86,7 @@ export const BashTool = {
|
|
|
49
86
|
let killed = false;
|
|
50
87
|
let exitCode = null;
|
|
51
88
|
|
|
52
|
-
const proc = spawn('bash', ['-c',
|
|
89
|
+
const proc = spawn('bash', ['-c', command], {
|
|
53
90
|
env: { ...process.env },
|
|
54
91
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
92
|
timeout: 0, // we handle timeout ourselves
|
|
@@ -145,4 +182,4 @@ function runBackground(command) {
|
|
|
145
182
|
return `Background job started: id=${id}, pid=${proc.pid}`;
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
export { backgroundJobs };
|
|
185
|
+
export { backgroundJobs, shouldSandbox, applySandbox };
|
package/src/ui/commands.mjs
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Commands are invoked via /command-name in the REPL.
|
|
6
|
-
*/
|
|
7
|
-
|
|
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';
|
|
8
5
|
import { SessionManager } from '../core/session.mjs';
|
|
9
6
|
import { CheckpointManager } from '../core/checkpoints.mjs';
|
|
10
7
|
import { PromptCache } from '../core/cache.mjs';
|
|
11
8
|
import { readEnv, listEnvVars } from '../config/env.mjs';
|
|
9
|
+
import { cronStore as _cronStore } from '../tools/cron-create.mjs';
|
|
12
10
|
import * as telemetry from '../telemetry/index.mjs';
|
|
13
11
|
|
|
14
12
|
const checkpoints = new CheckpointManager();
|
|
15
13
|
const promptCache = new PromptCache();
|
|
16
14
|
let sessionManager = null;
|
|
17
|
-
|
|
18
15
|
function getSession() {
|
|
19
16
|
if (!sessionManager) sessionManager = new SessionManager();
|
|
20
17
|
return sessionManager;
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
/**
|
|
24
|
-
* All slash commands.
|
|
25
|
-
*/
|
|
26
20
|
export const COMMANDS = {
|
|
27
21
|
'/help': {
|
|
28
22
|
description: 'Show available commands',
|
|
@@ -31,7 +25,6 @@ export const COMMANDS = {
|
|
|
31
25
|
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
|
32
26
|
lines.push(` ${name.padEnd(20)} ${cmd.description}`);
|
|
33
27
|
}
|
|
34
|
-
lines.push('');
|
|
35
28
|
return lines.join('\n');
|
|
36
29
|
},
|
|
37
30
|
},
|
|
@@ -74,11 +67,13 @@ export const COMMANDS = {
|
|
|
74
67
|
description: 'Show token usage and estimated cost',
|
|
75
68
|
handler(args, state) {
|
|
76
69
|
const { input, output } = state.tokenUsage;
|
|
77
|
-
// Use model-appropriate pricing
|
|
78
70
|
const model = state.model || '';
|
|
79
71
|
let priceIn = 3, priceOut = 15; // Sonnet default
|
|
80
72
|
if (model.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
|
|
81
73
|
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; }
|
|
82
77
|
|
|
83
78
|
const costIn = (input / 1_000_000) * priceIn;
|
|
84
79
|
const costOut = (output / 1_000_000) * priceOut;
|
|
@@ -98,22 +93,28 @@ export const COMMANDS = {
|
|
|
98
93
|
const checks = [];
|
|
99
94
|
checks.push(`Node.js: ${process.version}`);
|
|
100
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'}`);
|
|
101
98
|
checks.push(`Model: ${state.model || 'default'}`);
|
|
102
99
|
checks.push(`Tools: ${state.tools?.list?.()?.length || 0}`);
|
|
103
100
|
checks.push(`Messages: ${state.messages.length}`);
|
|
104
101
|
checks.push(`CWD: ${process.cwd()}`);
|
|
105
102
|
checks.push(`Platform: ${process.platform}`);
|
|
106
103
|
|
|
107
|
-
//
|
|
108
|
-
let apiStatus = '
|
|
109
|
-
if (process.env.ANTHROPIC_API_KEY)
|
|
110
|
-
|
|
111
|
-
|
|
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`);
|
|
112
109
|
checks.push(`API: ${apiStatus}`);
|
|
113
110
|
|
|
114
111
|
// Check MCP servers
|
|
115
|
-
const
|
|
116
|
-
checks.push(`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
|
+
}
|
|
117
118
|
|
|
118
119
|
return `System check:\n${checks.map(c => ` ${c}`).join('\n')}`;
|
|
119
120
|
},
|
|
@@ -135,8 +136,9 @@ export const COMMANDS = {
|
|
|
135
136
|
description: 'Show or switch model',
|
|
136
137
|
handler(args, state) {
|
|
137
138
|
if (args) {
|
|
139
|
+
const old = state.model;
|
|
138
140
|
state.model = args;
|
|
139
|
-
return `Model switched
|
|
141
|
+
return `Model switched: ${old} -> ${args}`;
|
|
140
142
|
}
|
|
141
143
|
return `Current model: ${state.model || 'default'}`;
|
|
142
144
|
},
|
|
@@ -166,28 +168,14 @@ export const COMMANDS = {
|
|
|
166
168
|
},
|
|
167
169
|
},
|
|
168
170
|
|
|
169
|
-
'/quit': {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
},
|
|
173
|
-
|
|
174
|
-
'/exit': {
|
|
175
|
-
description: 'Exit the REPL',
|
|
176
|
-
handler() { return 'EXIT'; },
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
'/bug': {
|
|
180
|
-
description: 'Report a bug',
|
|
181
|
-
handler() {
|
|
182
|
-
return 'Report bugs at: https://github.com/ruvnet/open-claude-code/issues';
|
|
183
|
-
},
|
|
184
|
-
},
|
|
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'; } },
|
|
185
174
|
|
|
186
175
|
'/review': {
|
|
187
176
|
description: 'Review recent changes',
|
|
188
177
|
handler(args, state) {
|
|
189
178
|
try {
|
|
190
|
-
const { execSync } = require('child_process');
|
|
191
179
|
const diff = execSync('git diff --stat HEAD~1 2>/dev/null || echo "No git history"', { encoding: 'utf-8' });
|
|
192
180
|
return `Recent changes:\n${diff}`;
|
|
193
181
|
} catch {
|
|
@@ -199,8 +187,6 @@ export const COMMANDS = {
|
|
|
199
187
|
'/init': {
|
|
200
188
|
description: 'Initialize Claude Code in current directory',
|
|
201
189
|
handler() {
|
|
202
|
-
const fs = require('fs');
|
|
203
|
-
const path = require('path');
|
|
204
190
|
const claudeDir = path.join(process.cwd(), '.claude');
|
|
205
191
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
206
192
|
const settingsFile = path.join(claudeDir, 'settings.json');
|
|
@@ -211,24 +197,11 @@ export const COMMANDS = {
|
|
|
211
197
|
},
|
|
212
198
|
},
|
|
213
199
|
|
|
214
|
-
'/login': {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return 'API key set.';
|
|
220
|
-
}
|
|
221
|
-
return 'Usage: /login <api-key>';
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
|
|
225
|
-
'/logout': {
|
|
226
|
-
description: 'Clear API key',
|
|
227
|
-
handler() {
|
|
228
|
-
delete process.env.ANTHROPIC_API_KEY;
|
|
229
|
-
return 'API key cleared.';
|
|
230
|
-
},
|
|
231
|
-
},
|
|
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.'; } },
|
|
232
205
|
|
|
233
206
|
'/status': {
|
|
234
207
|
description: 'Show session status',
|
|
@@ -386,10 +359,9 @@ export const COMMANDS = {
|
|
|
386
359
|
'/schedule': {
|
|
387
360
|
description: 'List scheduled tasks',
|
|
388
361
|
handler() {
|
|
389
|
-
|
|
390
|
-
if (!cronStore || cronStore.size === 0) return 'No scheduled tasks.';
|
|
362
|
+
if (!_cronStore || _cronStore.size === 0) return 'No scheduled tasks.';
|
|
391
363
|
const lines = [];
|
|
392
|
-
for (const [, job] of
|
|
364
|
+
for (const [, job] of _cronStore) {
|
|
393
365
|
lines.push(` ${job.id}: ${job.name} (${job.schedule})`);
|
|
394
366
|
}
|
|
395
367
|
return `Scheduled:\n${lines.join('\n')}`;
|
|
@@ -423,8 +395,9 @@ export const COMMANDS = {
|
|
|
423
395
|
description: 'Show git diff',
|
|
424
396
|
handler() {
|
|
425
397
|
try {
|
|
426
|
-
|
|
427
|
-
|
|
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.';
|
|
428
401
|
} catch {
|
|
429
402
|
return 'Unable to show diff.';
|
|
430
403
|
}
|
|
@@ -443,7 +416,7 @@ export const COMMANDS = {
|
|
|
443
416
|
description: 'Create a git commit with AI message',
|
|
444
417
|
handler(args) {
|
|
445
418
|
try {
|
|
446
|
-
|
|
419
|
+
// execSync imported at top
|
|
447
420
|
const msg = args || 'Update from open-claude-code';
|
|
448
421
|
execSync('git add -A', { encoding: 'utf-8' });
|
|
449
422
|
execSync(`git commit -m "${msg}"`, { encoding: 'utf-8' });
|
|
@@ -454,27 +427,45 @@ export const COMMANDS = {
|
|
|
454
427
|
},
|
|
455
428
|
},
|
|
456
429
|
|
|
457
|
-
'/pr': {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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>'; } },
|
|
432
|
+
|
|
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.';
|
|
461
451
|
},
|
|
462
452
|
},
|
|
463
453
|
|
|
464
|
-
'/
|
|
465
|
-
description: '
|
|
466
|
-
handler() {
|
|
467
|
-
|
|
454
|
+
'/sessions': {
|
|
455
|
+
description: 'List recent sessions',
|
|
456
|
+
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')}`;
|
|
468
464
|
},
|
|
469
465
|
},
|
|
470
466
|
};
|
|
471
467
|
|
|
472
|
-
/**
|
|
473
|
-
* Execute a slash command.
|
|
474
|
-
* @param {string} input - full command string (e.g., "/model claude-sonnet-4-6")
|
|
475
|
-
* @param {object} state - agent loop state
|
|
476
|
-
* @returns {{ response: string, exit: boolean }}
|
|
477
|
-
*/
|
|
468
|
+
/** Execute a slash command. */
|
|
478
469
|
export function executeCommand(input, state) {
|
|
479
470
|
const parts = input.split(/\s+/);
|
|
480
471
|
const cmd = parts[0].toLowerCase();
|
|
@@ -489,11 +480,7 @@ export function executeCommand(input, state) {
|
|
|
489
480
|
return { response, exit: response === 'EXIT' };
|
|
490
481
|
}
|
|
491
482
|
|
|
492
|
-
/**
|
|
493
|
-
* Get command completions for tab-complete.
|
|
494
|
-
* @param {string} partial
|
|
495
|
-
* @returns {string[]}
|
|
496
|
-
*/
|
|
483
|
+
/** Get command completions for tab-complete. */
|
|
497
484
|
export function getCompletions(partial) {
|
|
498
485
|
return Object.keys(COMMANDS).filter(c => c.startsWith(partial));
|
|
499
486
|
}
|
package/src/ui/ink-app.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
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)
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
// ANSI color codes
|
|
@@ -29,6 +30,7 @@ const COLORS = {
|
|
|
29
30
|
bgGreen: '\x1b[42m',
|
|
30
31
|
bgYellow: '\x1b[43m',
|
|
31
32
|
bgBlue: '\x1b[44m',
|
|
33
|
+
bgGray: '\x1b[48;5;236m',
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
const noColor = process.env.NO_COLOR === '1';
|
|
@@ -78,6 +80,7 @@ export class Spinner {
|
|
|
78
80
|
|
|
79
81
|
/**
|
|
80
82
|
* Highlight code blocks in markdown text.
|
|
83
|
+
* Detects ``` blocks and adds subtle background/syntax coloring.
|
|
81
84
|
* @param {string} text
|
|
82
85
|
* @returns {string}
|
|
83
86
|
*/
|
|
@@ -88,7 +91,7 @@ export function highlightCode(text) {
|
|
|
88
91
|
const header = lang ? c('dim', ` ${lang}`) : '';
|
|
89
92
|
const highlighted = code
|
|
90
93
|
.split('\n')
|
|
91
|
-
.map(line =>
|
|
94
|
+
.map(line => `${COLORS.bgGray} ${highlightLine(line, lang)} ${COLORS.reset}`)
|
|
92
95
|
.join('\n');
|
|
93
96
|
return `${c('dim', '```')}${header}\n${highlighted}\n${c('dim', '```')}`;
|
|
94
97
|
});
|
|
@@ -138,16 +141,18 @@ export function renderMarkdown(text) {
|
|
|
138
141
|
|
|
139
142
|
/**
|
|
140
143
|
* Render a tool progress indicator.
|
|
144
|
+
* Shows [ToolName] running... in yellow.
|
|
141
145
|
* @param {string} toolName
|
|
142
146
|
* @param {string} status
|
|
143
147
|
*/
|
|
144
148
|
export function renderToolProgress(toolName, status) {
|
|
145
149
|
const icon = status === 'running' ? c('yellow', '>>') : c('green', '>>');
|
|
146
|
-
return `${icon} ${c('yellow', toolName)} ${c('dim', status)}`;
|
|
150
|
+
return `${icon} ${c('yellow', `[${toolName}]`)} ${c('dim', status)}`;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
/**
|
|
150
154
|
* Render a status bar with token counts and cost estimate.
|
|
155
|
+
* Shown after each turn: (tokens: X in / Y out | $Z)
|
|
151
156
|
* @param {object} state
|
|
152
157
|
*/
|
|
153
158
|
export function renderStatusBar(state) {
|
|
@@ -156,25 +161,59 @@ export function renderStatusBar(state) {
|
|
|
156
161
|
const model = state.model || 'default';
|
|
157
162
|
const inp = state.tokenUsage?.input || 0;
|
|
158
163
|
const out = state.tokenUsage?.output || 0;
|
|
159
|
-
const tokens = `in:${inp} out:${out}`;
|
|
160
|
-
const turns = `turn:${state.turnCount || 0}`;
|
|
161
164
|
|
|
162
|
-
// Cost estimate
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
|
165
175
|
const cost = `$${(costIn + costOut).toFixed(4)}`;
|
|
176
|
+
const turns = `turn:${state.turnCount || 0}`;
|
|
166
177
|
|
|
167
|
-
const
|
|
178
|
+
const costDisplay = `(tokens: ${formatNum(inp)} in / ${formatNum(out)} out | ${cost})`;
|
|
179
|
+
const right = `${model} | ${costDisplay} | ${turns}`;
|
|
168
180
|
const padding = Math.max(0, cols - right.length - 1);
|
|
169
181
|
return c('dim', `${' '.repeat(padding)}${right}`);
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
/**
|
|
173
|
-
*
|
|
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.
|
|
174
208
|
* @param {string} message
|
|
209
|
+
* @param {string} [context]
|
|
175
210
|
*/
|
|
176
|
-
export function renderError(message) {
|
|
177
|
-
|
|
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;
|
|
178
217
|
}
|
|
179
218
|
|
|
180
219
|
/**
|
|
@@ -194,11 +233,11 @@ export function renderSuccess(message) {
|
|
|
194
233
|
}
|
|
195
234
|
|
|
196
235
|
/**
|
|
197
|
-
* Render thinking output (dim).
|
|
236
|
+
* Render thinking output (dim/gray).
|
|
198
237
|
* @param {string} text
|
|
199
238
|
*/
|
|
200
239
|
export function renderThinking(text) {
|
|
201
|
-
return c('
|
|
240
|
+
return c('gray', text);
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
/**
|