@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.
@@ -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, block Bash/Agent unless rl available
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
- return !requiresPermission(toolName) || !!config.bypassBash;
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': return toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep';
62
+ case 'plan':
63
+ return ['Read', 'Glob', 'Grep', 'LS', 'ToolSearch'].includes(toolName);
46
64
  case 'default':
47
65
  default:
48
- // In default mode, safe tools pass through
66
+ // Safe tools always pass
49
67
  if (!requiresPermission(toolName)) return true;
50
- // Without a readline interface, allow (headless mode)
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
- // With rl, would call promptPermission — but that's async/interactive
53
- return true;
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
  }
@@ -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(input.command);
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', input.command], {
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 };
@@ -1,28 +1,22 @@
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
-
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
- // Check API connectivity
108
- let apiStatus = 'unchecked';
109
- if (process.env.ANTHROPIC_API_KEY) {
110
- apiStatus = 'key present';
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 mcpCount = state._mcpClients?.length || 0;
116
- checks.push(`MCP servers: ${mcpCount}`);
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 to: ${args}`;
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
- description: 'Exit the REPL',
171
- handler() { return 'EXIT'; },
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
- description: 'Set API key',
216
- handler(args) {
217
- if (args) {
218
- process.env.ANTHROPIC_API_KEY = args;
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
- const { cronStore } = require('../tools/cron-create.mjs');
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 cronStore) {
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
- const { execSync } = require('child_process');
427
- return execSync('git diff --stat 2>/dev/null || echo "Not in a git repo"', { encoding: 'utf-8' });
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
- const { execSync } = require('child_process');
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
- description: 'Create a pull request (stub)',
459
- handler() {
460
- return 'PR creation requires gh CLI. Run: gh pr create --fill';
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
- '/release': {
465
- description: 'Create a release (stub)',
466
- handler() {
467
- return 'Release creation requires gh CLI. Run: gh release create <tag>';
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
  }
@@ -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 => ` ${highlightLine(line, lang)}`)
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 (Sonnet pricing by default)
163
- const costIn = (inp / 1_000_000) * 3;
164
- const costOut = (out / 1_000_000) * 15;
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 right = `${model} | ${tokens} | ${cost} | ${turns}`;
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
- * Render an error message.
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
- return `${c('red', 'Error:')} ${message}`;
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('dim', text);
240
+ return c('gray', text);
202
241
  }
203
242
 
204
243
  /**