@ruvnet/open-claude-code 2.0.1 → 2.1.0

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/src/ui/app.mjs ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Main Ink App — full React terminal UI matching Claude Code's interface.
3
+ *
4
+ * Uses React.createElement (no JSX transpiler needed).
5
+ *
6
+ * Features:
7
+ * - Scrolling messages area with assistant, user, tool, thinking messages
8
+ * - Persistent status bar (model, context%, cost, time, permission mode)
9
+ * - Text input with prompt indicator
10
+ * - Loading spinner during API calls
11
+ * - Tool execution display with spinners
12
+ * - Keyboard shortcuts (Escape, Ctrl+C, Ctrl+L)
13
+ * - Slash command support (39 commands)
14
+ * - Skill invocation via /skill-name
15
+ */
16
+
17
+ import React, { useState, useCallback, useRef } from 'react';
18
+ import { render, Box, Text, useInput, useApp } from 'ink';
19
+ import TextInput from 'ink-text-input';
20
+ import {
21
+ StatusBar,
22
+ Message,
23
+ LoadingIndicator,
24
+ WelcomeBanner,
25
+ } from './components.mjs';
26
+ import { executeCommand, COMMANDS } from './commands.mjs';
27
+
28
+ const h = React.createElement;
29
+
30
+ /**
31
+ * Main application component.
32
+ */
33
+ export function App({ agentLoop, settings }) {
34
+ const [input, setInput] = useState('');
35
+ const [messages, setMessages] = useState([]);
36
+ const [isLoading, setIsLoading] = useState(false);
37
+ const [model, setModel] = useState(agentLoop.state.model || 'claude-sonnet-4-6');
38
+ const [tokenCount, setTokenCount] = useState({ input: 0, output: 0 });
39
+ const [cost, setCost] = useState(0);
40
+ const [mode] = useState(settings.permissions?.defaultMode || 'default');
41
+ const [toolStatus, setToolStatus] = useState(null);
42
+ const [startTime] = useState(Date.now());
43
+ const loadingRef = useRef(false);
44
+ const { exit } = useApp();
45
+
46
+ // Calculate cost from token counts
47
+ const updateCost = useCallback((tokens) => {
48
+ const m = agentLoop.state.model || '';
49
+ let priceIn = 3, priceOut = 15;
50
+ if (m.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
51
+ if (m.includes('opus')) { priceIn = 15; priceOut = 75; }
52
+ const costIn = (tokens.input / 1_000_000) * priceIn;
53
+ const costOut = (tokens.output / 1_000_000) * priceOut;
54
+ setCost(costIn + costOut);
55
+ }, [agentLoop.state.model]);
56
+
57
+ // Add a message to the display
58
+ const addMessage = useCallback((msg) => {
59
+ setMessages(prev => {
60
+ const next = [...prev, msg];
61
+ return next.length > 200 ? next.slice(-150) : next;
62
+ });
63
+ }, []);
64
+
65
+ // Handle slash commands
66
+ const handleCommand = useCallback((text) => {
67
+ const cmdName = text.split(/\s+/)[0].toLowerCase();
68
+
69
+ // Check for skill invocation
70
+ if (!COMMANDS[cmdName] && agentLoop.state._skillsLoader) {
71
+ const skill = agentLoop.state._skillsLoader.get(cmdName.slice(1));
72
+ if (skill) {
73
+ const args = text.slice(cmdName.length).trim();
74
+ addMessage({ role: 'user', content: text });
75
+ runPrompt(
76
+ `[Skill: ${skill.name}]\n${skill.prompt}${args ? `\nArguments: ${args}` : ''}`
77
+ );
78
+ return;
79
+ }
80
+ }
81
+
82
+ const { response, exit: shouldExit } = executeCommand(text, agentLoop.state);
83
+ if (shouldExit) {
84
+ exit();
85
+ return;
86
+ }
87
+ addMessage({ role: 'system', content: response });
88
+
89
+ // Sync model if it changed
90
+ if (agentLoop.state.model !== model) {
91
+ setModel(agentLoop.state.model);
92
+ }
93
+ }, [agentLoop, model, addMessage, exit]);
94
+
95
+ // Run a prompt through the agent loop
96
+ const runPrompt = useCallback(async (text) => {
97
+ setIsLoading(true);
98
+ loadingRef.current = true;
99
+ setToolStatus(null);
100
+
101
+ try {
102
+ for await (const event of agentLoop.run(text)) {
103
+ switch (event.type) {
104
+ case 'stream_event':
105
+ setMessages(prev => {
106
+ const last = prev[prev.length - 1];
107
+ if (last && last.role === 'assistant' && last._streaming) {
108
+ return [
109
+ ...prev.slice(0, -1),
110
+ { ...last, content: (last.content || '') + (event.text || '') },
111
+ ];
112
+ }
113
+ return [
114
+ ...prev,
115
+ { role: 'assistant', content: event.text || '', _streaming: true },
116
+ ];
117
+ });
118
+ break;
119
+
120
+ case 'assistant':
121
+ setMessages(prev => {
122
+ const last = prev[prev.length - 1];
123
+ if (last && last.role === 'assistant' && last._streaming) {
124
+ return [
125
+ ...prev.slice(0, -1),
126
+ { ...last, _streaming: false },
127
+ ];
128
+ }
129
+ if (event.content) {
130
+ return [...prev, { role: 'assistant', content: event.content }];
131
+ }
132
+ return prev;
133
+ });
134
+ break;
135
+
136
+ case 'thinking':
137
+ addMessage({ role: 'thinking', thinking: event.text });
138
+ break;
139
+
140
+ case 'tool_progress':
141
+ setToolStatus(event.tool);
142
+ addMessage({
143
+ role: 'tool',
144
+ toolName: event.tool,
145
+ toolStatus: 'running',
146
+ });
147
+ break;
148
+
149
+ case 'result':
150
+ setToolStatus(null);
151
+ setMessages(prev => {
152
+ const idx = prev.findLastIndex(
153
+ m => m.role === 'tool' &&
154
+ m.toolName === event.tool &&
155
+ m.toolStatus === 'running'
156
+ );
157
+ if (idx >= 0) {
158
+ const updated = [...prev];
159
+ updated[idx] = {
160
+ ...updated[idx],
161
+ toolResult: typeof event.result === 'string'
162
+ ? event.result
163
+ : JSON.stringify(event.result),
164
+ toolStatus: 'done',
165
+ };
166
+ return updated;
167
+ }
168
+ return prev;
169
+ });
170
+ break;
171
+
172
+ case 'compaction':
173
+ addMessage({ role: 'system', content: `[compaction #${event.count}]` });
174
+ break;
175
+
176
+ case 'hookPermissionResult':
177
+ if (!event.allowed) {
178
+ addMessage({ role: 'system', content: `[blocked: ${event.tool}]` });
179
+ }
180
+ break;
181
+
182
+ case 'error':
183
+ addMessage({ role: 'error', content: event.message });
184
+ break;
185
+
186
+ case 'stop':
187
+ break;
188
+ }
189
+
190
+ // Update token counts
191
+ const usage = agentLoop.state.tokenUsage;
192
+ setTokenCount({ input: usage.input, output: usage.output });
193
+ updateCost(usage);
194
+ }
195
+ } catch (err) {
196
+ addMessage({ role: 'error', content: err.message });
197
+ } finally {
198
+ setIsLoading(false);
199
+ loadingRef.current = false;
200
+ setToolStatus(null);
201
+ }
202
+ }, [agentLoop, addMessage, updateCost]);
203
+
204
+ // Handle submit
205
+ const handleSubmit = useCallback((value) => {
206
+ const trimmed = value.trim();
207
+ if (!trimmed) return;
208
+
209
+ setInput('');
210
+
211
+ if (trimmed.startsWith('/')) {
212
+ handleCommand(trimmed);
213
+ return;
214
+ }
215
+
216
+ addMessage({ role: 'user', content: trimmed });
217
+ runPrompt(trimmed);
218
+ }, [handleCommand, addMessage, runPrompt]);
219
+
220
+ // Keyboard shortcuts
221
+ useInput((ch, key) => {
222
+ if (key.escape && loadingRef.current) {
223
+ setIsLoading(false);
224
+ setToolStatus(null);
225
+ addMessage({ role: 'system', content: '[cancelled]' });
226
+ }
227
+ if (key.ctrl && ch === 'c') {
228
+ exit();
229
+ }
230
+ if (key.ctrl && ch === 'l') {
231
+ setMessages([]);
232
+ }
233
+ });
234
+
235
+ const toolCount = agentLoop.state.tools?.list?.()?.length || 0;
236
+
237
+ // Build the message list
238
+ const messageElements = messages.map((msg, i) =>
239
+ h(Message, { key: i, ...msg })
240
+ );
241
+
242
+ return h(Box, { flexDirection: 'column' },
243
+ // Welcome banner
244
+ h(WelcomeBanner, { model, toolCount }),
245
+
246
+ // Messages area
247
+ h(Box, { flexDirection: 'column', flexGrow: 1 },
248
+ ...messageElements,
249
+ isLoading ? h(LoadingIndicator, { tool: toolStatus }) : null,
250
+ ),
251
+
252
+ // Input area
253
+ h(Box, { borderStyle: 'round', borderColor: 'cyan', paddingX: 1, marginTop: 1 },
254
+ h(Text, { color: 'cyan' }, '> '),
255
+ h(TextInput, {
256
+ value: input,
257
+ onChange: setInput,
258
+ onSubmit: handleSubmit,
259
+ placeholder: 'Type a message...',
260
+ }),
261
+ ),
262
+
263
+ // Status bar
264
+ h(StatusBar, {
265
+ model,
266
+ tokens: tokenCount,
267
+ cost,
268
+ mode,
269
+ startTime,
270
+ contextMax: settings.maxContextTokens || 200000,
271
+ }),
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Start the Ink application.
277
+ * @param {object} agentLoop - agent loop instance
278
+ * @param {object} settings - loaded settings
279
+ * @returns {object} Ink instance
280
+ */
281
+ export function startInkApp(agentLoop, settings) {
282
+ const instance = render(
283
+ h(App, { agentLoop, settings })
284
+ );
285
+ return instance;
286
+ }
@@ -1,22 +1,28 @@
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';
10
12
  import * as telemetry from '../telemetry/index.mjs';
11
13
 
12
14
  const checkpoints = new CheckpointManager();
13
15
  const promptCache = new PromptCache();
14
16
  let sessionManager = null;
17
+
15
18
  function getSession() {
16
19
  if (!sessionManager) sessionManager = new SessionManager();
17
20
  return sessionManager;
18
21
  }
19
22
 
23
+ /**
24
+ * All slash commands.
25
+ */
20
26
  export const COMMANDS = {
21
27
  '/help': {
22
28
  description: 'Show available commands',
@@ -25,6 +31,7 @@ export const COMMANDS = {
25
31
  for (const [name, cmd] of Object.entries(COMMANDS)) {
26
32
  lines.push(` ${name.padEnd(20)} ${cmd.description}`);
27
33
  }
34
+ lines.push('');
28
35
  return lines.join('\n');
29
36
  },
30
37
  },
@@ -67,13 +74,11 @@ export const COMMANDS = {
67
74
  description: 'Show token usage and estimated cost',
68
75
  handler(args, state) {
69
76
  const { input, output } = state.tokenUsage;
77
+ // Use model-appropriate pricing
70
78
  const model = state.model || '';
71
79
  let priceIn = 3, priceOut = 15; // Sonnet default
72
80
  if (model.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
73
81
  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
82
 
78
83
  const costIn = (input / 1_000_000) * priceIn;
79
84
  const costOut = (output / 1_000_000) * priceOut;
@@ -93,28 +98,22 @@ export const COMMANDS = {
93
98
  const checks = [];
94
99
  checks.push(`Node.js: ${process.version}`);
95
100
  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'}`);
98
101
  checks.push(`Model: ${state.model || 'default'}`);
99
102
  checks.push(`Tools: ${state.tools?.list?.()?.length || 0}`);
100
103
  checks.push(`Messages: ${state.messages.length}`);
101
104
  checks.push(`CWD: ${process.cwd()}`);
102
105
  checks.push(`Platform: ${process.platform}`);
103
106
 
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`);
107
+ // Check API connectivity
108
+ let apiStatus = 'unchecked';
109
+ if (process.env.ANTHROPIC_API_KEY) {
110
+ apiStatus = 'key present';
111
+ }
109
112
  checks.push(`API: ${apiStatus}`);
110
113
 
111
114
  // 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
- }
115
+ const mcpCount = state._mcpClients?.length || 0;
116
+ checks.push(`MCP servers: ${mcpCount}`);
118
117
 
119
118
  return `System check:\n${checks.map(c => ` ${c}`).join('\n')}`;
120
119
  },
@@ -136,9 +135,8 @@ export const COMMANDS = {
136
135
  description: 'Show or switch model',
137
136
  handler(args, state) {
138
137
  if (args) {
139
- const old = state.model;
140
138
  state.model = args;
141
- return `Model switched: ${old} -> ${args}`;
139
+ return `Model switched to: ${args}`;
142
140
  }
143
141
  return `Current model: ${state.model || 'default'}`;
144
142
  },
@@ -168,14 +166,28 @@ export const COMMANDS = {
168
166
  },
169
167
  },
170
168
 
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'; } },
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
+ },
174
185
 
175
186
  '/review': {
176
187
  description: 'Review recent changes',
177
188
  handler(args, state) {
178
189
  try {
190
+ const { execSync } = require('child_process');
179
191
  const diff = execSync('git diff --stat HEAD~1 2>/dev/null || echo "No git history"', { encoding: 'utf-8' });
180
192
  return `Recent changes:\n${diff}`;
181
193
  } catch {
@@ -187,6 +199,8 @@ export const COMMANDS = {
187
199
  '/init': {
188
200
  description: 'Initialize Claude Code in current directory',
189
201
  handler() {
202
+ const fs = require('fs');
203
+ const path = require('path');
190
204
  const claudeDir = path.join(process.cwd(), '.claude');
191
205
  fs.mkdirSync(claudeDir, { recursive: true });
192
206
  const settingsFile = path.join(claudeDir, 'settings.json');
@@ -197,11 +211,24 @@ export const COMMANDS = {
197
211
  },
198
212
  },
199
213
 
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.'; } },
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
+ },
205
232
 
206
233
  '/status': {
207
234
  description: 'Show session status',
@@ -359,9 +386,10 @@ export const COMMANDS = {
359
386
  '/schedule': {
360
387
  description: 'List scheduled tasks',
361
388
  handler() {
362
- if (!_cronStore || _cronStore.size === 0) return 'No scheduled tasks.';
389
+ const { cronStore } = require('../tools/cron-create.mjs');
390
+ if (!cronStore || cronStore.size === 0) return 'No scheduled tasks.';
363
391
  const lines = [];
364
- for (const [, job] of _cronStore) {
392
+ for (const [, job] of cronStore) {
365
393
  lines.push(` ${job.id}: ${job.name} (${job.schedule})`);
366
394
  }
367
395
  return `Scheduled:\n${lines.join('\n')}`;
@@ -395,9 +423,8 @@ export const COMMANDS = {
395
423
  description: 'Show git diff',
396
424
  handler() {
397
425
  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.';
426
+ const { execSync } = require('child_process');
427
+ return execSync('git diff --stat 2>/dev/null || echo "Not in a git repo"', { encoding: 'utf-8' });
401
428
  } catch {
402
429
  return 'Unable to show diff.';
403
430
  }
@@ -416,7 +443,7 @@ export const COMMANDS = {
416
443
  description: 'Create a git commit with AI message',
417
444
  handler(args) {
418
445
  try {
419
- // execSync imported at top
446
+ const { execSync } = require('child_process');
420
447
  const msg = args || 'Update from open-claude-code';
421
448
  execSync('git add -A', { encoding: 'utf-8' });
422
449
  execSync(`git commit -m "${msg}"`, { encoding: 'utf-8' });
@@ -427,45 +454,27 @@ export const COMMANDS = {
427
454
  },
428
455
  },
429
456
 
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.';
457
+ '/pr': {
458
+ description: 'Create a pull request (stub)',
459
+ handler() {
460
+ return 'PR creation requires gh CLI. Run: gh pr create --fill';
451
461
  },
452
462
  },
453
463
 
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')}`;
464
+ '/release': {
465
+ description: 'Create a release (stub)',
466
+ handler() {
467
+ return 'Release creation requires gh CLI. Run: gh release create <tag>';
464
468
  },
465
469
  },
466
470
  };
467
471
 
468
- /** Execute a slash command. */
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
+ */
469
478
  export function executeCommand(input, state) {
470
479
  const parts = input.split(/\s+/);
471
480
  const cmd = parts[0].toLowerCase();
@@ -480,7 +489,11 @@ export function executeCommand(input, state) {
480
489
  return { response, exit: response === 'EXIT' };
481
490
  }
482
491
 
483
- /** Get command completions for tab-complete. */
492
+ /**
493
+ * Get command completions for tab-complete.
494
+ * @param {string} partial
495
+ * @returns {string[]}
496
+ */
484
497
  export function getCompletions(partial) {
485
498
  return Object.keys(COMMANDS).filter(c => c.startsWith(partial));
486
499
  }