@ruvnet/open-claude-code 2.0.0 → 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/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # open-claude-code v2 — Technical Guide
2
+
3
+ ## Quick Start
4
+
5
+ ```bash
6
+ export ANTHROPIC_API_KEY=sk-ant-...
7
+ node src/index.mjs "hello" # one-shot
8
+ node src/index.mjs # interactive REPL
9
+ node src/index.mjs -m claude-opus-4-6 -p "explain this" # print mode
10
+ ```
11
+
12
+ ## Architecture
13
+
14
+ ```
15
+ v2/src/
16
+ ├── core/ # Core engine
17
+ │ ├── agent-loop.mjs # Async generator (13 event types, recursive)
18
+ │ ├── streaming.mjs # SSE handler (all event types)
19
+ │ ├── context-manager.mjs # Token tracking + compaction
20
+ │ ├── system-prompt.mjs # CLAUDE.md loading + cache boundary
21
+ │ ├── session.mjs # Save/resume/teleport
22
+ │ ├── checkpoints.mjs # File checkpointing + undo
23
+ │ ├── cache.mjs # Prompt caching
24
+ │ ├── rate-limiter.mjs # 429/529 handling + backoff
25
+ │ ├── providers.mjs # 5 AI providers
26
+ │ └── scheduler.mjs # Cron task scheduling
27
+ ├── tools/ # 25 tools
28
+ │ ├── registry.mjs # validateInput/call interface
29
+ │ ├── bash.mjs # Shell (sandboxed, timeout, background)
30
+ │ ├── read.mjs # File read (PDF, binary detect, line nums)
31
+ │ ├── edit.mjs # Edit (replace_all, uniqueness check)
32
+ │ ├── write.mjs # Write (mkdir, overwrite protection)
33
+ │ ├── glob.mjs # Glob (proper matching, mtime sort)
34
+ │ ├── grep.mjs # Grep (-i/-n/-A/-B/-C, ripgrep)
35
+ │ ├── agent.mjs # Subagent (worktree, background, model)
36
+ │ ├── web-fetch.mjs # URL fetch
37
+ │ ├── web-search.mjs # Web search
38
+ │ ├── todo-write.mjs # Task management
39
+ │ ├── notebook-edit.mjs # Jupyter notebooks
40
+ │ ├── multi-edit.mjs # Atomic multi-file edits
41
+ │ ├── ls.mjs # Directory listing
42
+ │ ├── tool-search.mjs # Deferred tool discovery
43
+ │ ├── ask-user.mjs # User prompts
44
+ │ ├── skill.mjs # Skill invocation
45
+ │ ├── send-message.mjs # Agent team messaging
46
+ │ ├── cron-create.mjs # Scheduled tasks
47
+ │ ├── cron-delete.mjs
48
+ │ ├── cron-list.mjs
49
+ │ ├── enter-worktree.mjs # Git worktree
50
+ │ ├── exit-worktree.mjs
51
+ │ ├── remote-trigger.mjs # Remote execution
52
+ │ ├── lsp.mjs # Language server
53
+ │ └── read-mcp-resource.mjs
54
+ ├── mcp/ # MCP protocol
55
+ │ ├── client.mjs # JSON-RPC client
56
+ │ ├── transport-sse.mjs # SSE transport
57
+ │ ├── transport-shttp.mjs # Streamable HTTP
58
+ │ └── transport-ws.mjs # WebSocket
59
+ ├── permissions/ # Security
60
+ │ ├── checker.mjs # 6 modes + interactive prompts
61
+ │ ├── sandbox.mjs # bubblewrap/seatbelt
62
+ │ ├── injection-check.mjs # Command injection detection
63
+ │ ├── path-check.mjs # File path validation
64
+ │ └── prompt.mjs # Permission prompting
65
+ ├── hooks/
66
+ │ └── engine.mjs # PreToolUse/PostToolUse/Stop/Notification
67
+ ├── agents/
68
+ │ ├── loader.mjs # Agent definition loader
69
+ │ ├── parser.mjs # JSON/MD frontmatter parser
70
+ │ └── teams.mjs # Multi-agent teams
71
+ ├── skills/
72
+ │ ├── loader.mjs # Skill discovery
73
+ │ └── runner.mjs # Skill execution
74
+ ├── plugins/
75
+ │ └── loader.mjs # Plugin discovery + git clone
76
+ ├── auth/
77
+ │ └── oauth.mjs # PKCE OAuth flow
78
+ ├── config/
79
+ │ ├── settings.mjs # 4-source deep merge
80
+ │ ├── cli-args.mjs # All CLI flags
81
+ │ └── env.mjs # 104 env vars
82
+ ├── ui/
83
+ │ ├── repl.mjs # Interactive REPL
84
+ │ ├── ink-app.mjs # Rich terminal output
85
+ │ └── commands.mjs # 40 slash commands
86
+ ├── telemetry/
87
+ │ └── index.mjs # Telemetry stub
88
+ └── index.mjs # Entry point
89
+
90
+ test/
91
+ └── test.mjs # 1,581 tests
92
+ ```
93
+
94
+ ## Stats
95
+
96
+ | Metric | Value |
97
+ |--------|:-----:|
98
+ | Source files | 61 |
99
+ | Lines of code | 8,314 |
100
+ | Tests | 1,581 (0 failures) |
101
+ | Tools | 25 |
102
+ | Slash commands | 40 |
103
+ | MCP transports | 4 |
104
+ | AI providers | 5 |
105
+ | Env vars | 104 |
106
+ | Permission modes | 6 |
107
+
108
+ ## Tests
109
+
110
+ ```bash
111
+ node test/test.mjs
112
+ # Tests: 1581 total, 1581 passed, 0 failed
113
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruvnet/open-claude-code",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Open source Claude Code CLI — based on ruDevolution decompilation of v2.1.91",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,5 +23,23 @@
23
23
  "open-source"
24
24
  ],
25
25
  "license": "MIT",
26
- "repository": "https://github.com/ruvnet/open-claude-code"
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/ruvnet/open-claude-code.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/ruvnet/open-claude-code/issues"
32
+ },
33
+ "homepage": "https://github.com/ruvnet/open-claude-code#readme",
34
+ "main": "index.js",
35
+ "directories": {
36
+ "test": "test"
37
+ },
38
+ "author": "",
39
+ "dependencies": {
40
+ "ink": "^5.2.1",
41
+ "ink-spinner": "^5.0.0",
42
+ "ink-text-input": "^6.0.0",
43
+ "react": "^18.3.1"
44
+ }
27
45
  }
package/src/index.mjs CHANGED
@@ -141,7 +141,7 @@ async function main() {
141
141
  process.on('SIGTERM', async () => { await cleanup(); process.exit(0); });
142
142
 
143
143
  if (args.prompt) {
144
- // Non-interactive: run prompt and exit
144
+ // Non-interactive: run prompt and exit (no Ink — plain stdout)
145
145
  const outputFormat = args.outputFormat || 'text';
146
146
  const results = [];
147
147
 
@@ -172,9 +172,21 @@ async function main() {
172
172
 
173
173
  await cleanup();
174
174
  } else {
175
- // Interactive REPL
176
- const { startRepl } = await import('./ui/repl.mjs');
177
- await startRepl(loop, settings);
175
+ // Interactive: use Ink React TUI
176
+ try {
177
+ const { startInkApp } = await import('./ui/app.mjs');
178
+ const inkInstance = startInkApp(loop, settings);
179
+
180
+ // Wait for Ink to exit (user pressed Ctrl+C or /quit)
181
+ await inkInstance.waitUntilExit();
182
+ } catch (err) {
183
+ // Fallback to readline REPL if Ink fails (e.g. no TTY, missing deps)
184
+ if (settings.debug) {
185
+ console.error(`Ink UI unavailable (${err.message}), falling back to readline REPL`);
186
+ }
187
+ const { startRepl } = await import('./ui/repl.mjs');
188
+ await startRepl(loop, settings);
189
+ }
178
190
  await cleanup();
179
191
  }
180
192
  }
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
+ }
@@ -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
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Terminal Markdown Renderer — parse markdown to ANSI-styled text.
3
+ *
4
+ * Supports:
5
+ * - **bold** -> bold text
6
+ * - *italic* -> italic text
7
+ * - `code` -> inverse/cyan
8
+ * - ```code block``` -> bordered box
9
+ * - - list item -> bullet
10
+ * - # heading -> bold + underline
11
+ * - [link](url) -> blue underline
12
+ * - | table | -> formatted table
13
+ */
14
+
15
+ const ANSI = {
16
+ reset: '\x1b[0m',
17
+ bold: '\x1b[1m',
18
+ dim: '\x1b[2m',
19
+ italic: '\x1b[3m',
20
+ underline: '\x1b[4m',
21
+ inverse: '\x1b[7m',
22
+ red: '\x1b[31m',
23
+ green: '\x1b[32m',
24
+ yellow: '\x1b[33m',
25
+ blue: '\x1b[34m',
26
+ magenta: '\x1b[35m',
27
+ cyan: '\x1b[36m',
28
+ white: '\x1b[37m',
29
+ gray: '\x1b[90m',
30
+ };
31
+
32
+ const noColor = process.env.NO_COLOR === '1';
33
+
34
+ function a(codes, text) {
35
+ if (noColor) return text;
36
+ const prefix = Array.isArray(codes) ? codes.join('') : codes;
37
+ return `${prefix}${text}${ANSI.reset}`;
38
+ }
39
+
40
+ /**
41
+ * Render inline markdown formatting within a single line.
42
+ * @param {string} line
43
+ * @returns {string}
44
+ */
45
+ export function renderInline(line) {
46
+ if (noColor) return line;
47
+ let result = line;
48
+
49
+ // Bold: **text**
50
+ result = result.replace(/\*\*(.+?)\*\*/g, (_, t) => a(ANSI.bold, t));
51
+
52
+ // Italic: *text* (not preceded/followed by *)
53
+ result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, t) => a(ANSI.italic, t));
54
+
55
+ // Inline code: `text`
56
+ result = result.replace(/`([^`]+)`/g, (_, t) => a(ANSI.cyan, t));
57
+
58
+ // Links: [text](url)
59
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
60
+ return `${a([ANSI.blue, ANSI.underline], text)} ${a(ANSI.dim, `(${url})`)}`;
61
+ });
62
+
63
+ return result;
64
+ }
65
+
66
+ /**
67
+ * Highlight syntax within a code line (basic keyword/string/number/comment coloring).
68
+ * @param {string} line
69
+ * @param {string} lang
70
+ * @returns {string}
71
+ */
72
+ export function highlightSyntax(line, lang) {
73
+ if (noColor) return line;
74
+ let result = line;
75
+
76
+ // Strings
77
+ result = result.replace(/(["'`])(.*?)\1/g, (m, q, s) => a(ANSI.green, `${q}${s}${q}`));
78
+
79
+ // Keywords
80
+ const kw = /\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await|try|catch|throw|new|this|def|fn|pub|use|mod|struct|enum|impl|match|type|interface)\b/g;
81
+ result = result.replace(kw, (m) => a(ANSI.magenta, m));
82
+
83
+ // Numbers
84
+ result = result.replace(/\b(\d+\.?\d*)\b/g, (m) => a(ANSI.yellow, m));
85
+
86
+ // Comments
87
+ result = result.replace(/(\/\/.*|#.*)$/, (m) => a(ANSI.gray, m));
88
+
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Render a full markdown string to ANSI terminal output.
94
+ * Handles block-level elements (headings, code blocks, lists, tables)
95
+ * and inline formatting.
96
+ *
97
+ * @param {string} text - raw markdown
98
+ * @returns {string} - ANSI-formatted string
99
+ */
100
+ export function renderMarkdown(text) {
101
+ if (!text) return '';
102
+ const lines = text.split('\n');
103
+ const output = [];
104
+ let inCodeBlock = false;
105
+ let codeLang = '';
106
+ let codeLines = [];
107
+ let inTable = false;
108
+ let tableRows = [];
109
+
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i];
112
+
113
+ // Code block start/end
114
+ if (line.trimStart().startsWith('```')) {
115
+ if (inCodeBlock) {
116
+ // End code block - render it
117
+ output.push(formatCodeBlock(codeLines, codeLang));
118
+ inCodeBlock = false;
119
+ codeLines = [];
120
+ codeLang = '';
121
+ continue;
122
+ }
123
+ // Start code block
124
+ inCodeBlock = true;
125
+ codeLang = line.trimStart().slice(3).trim();
126
+ continue;
127
+ }
128
+
129
+ if (inCodeBlock) {
130
+ codeLines.push(line);
131
+ continue;
132
+ }
133
+
134
+ // Table detection
135
+ if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
136
+ // Check if separator row
137
+ if (/^\|[\s\-:|]+\|$/.test(line.trim())) {
138
+ // Table separator, skip
139
+ continue;
140
+ }
141
+ tableRows.push(line);
142
+ // Check if next line is NOT a table row
143
+ const nextLine = lines[i + 1];
144
+ if (!nextLine || (!nextLine.trim().startsWith('|') || !nextLine.trim().endsWith('|'))) {
145
+ // Flush table
146
+ if (tableRows.length > 0) {
147
+ output.push(formatTable(tableRows));
148
+ tableRows = [];
149
+ }
150
+ }
151
+ continue;
152
+ }
153
+
154
+ // Flush any pending table rows
155
+ if (tableRows.length > 0) {
156
+ output.push(formatTable(tableRows));
157
+ tableRows = [];
158
+ }
159
+
160
+ // Headings
161
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
162
+ if (headingMatch) {
163
+ const level = headingMatch[1].length;
164
+ const text = headingMatch[2];
165
+ if (level === 1) {
166
+ output.push(a([ANSI.bold, ANSI.underline], text));
167
+ } else if (level === 2) {
168
+ output.push(a(ANSI.bold, text));
169
+ } else {
170
+ output.push(a(ANSI.bold, text));
171
+ }
172
+ continue;
173
+ }
174
+
175
+ // Unordered list
176
+ const listMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
177
+ if (listMatch) {
178
+ const indent = listMatch[1];
179
+ const content = renderInline(listMatch[3]);
180
+ output.push(`${indent} * ${content}`);
181
+ continue;
182
+ }
183
+
184
+ // Ordered list
185
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
186
+ if (olMatch) {
187
+ const indent = olMatch[1];
188
+ const num = olMatch[2];
189
+ const content = renderInline(olMatch[3]);
190
+ output.push(`${indent} ${num}. ${content}`);
191
+ continue;
192
+ }
193
+
194
+ // Horizontal rule
195
+ if (/^[-*_]{3,}\s*$/.test(line)) {
196
+ const cols = process.stdout.columns || 80;
197
+ output.push(a(ANSI.dim, '\u2500'.repeat(Math.min(cols, 60))));
198
+ continue;
199
+ }
200
+
201
+ // Blockquote
202
+ const bqMatch = line.match(/^>\s?(.*)/);
203
+ if (bqMatch) {
204
+ output.push(a(ANSI.dim, ` | ${renderInline(bqMatch[1])}`));
205
+ continue;
206
+ }
207
+
208
+ // Normal line with inline formatting
209
+ output.push(renderInline(line));
210
+ }
211
+
212
+ // Flush remaining
213
+ if (inCodeBlock && codeLines.length > 0) {
214
+ output.push(formatCodeBlock(codeLines, codeLang));
215
+ }
216
+ if (tableRows.length > 0) {
217
+ output.push(formatTable(tableRows));
218
+ }
219
+
220
+ return output.join('\n');
221
+ }
222
+
223
+ /**
224
+ * Format a code block with a border.
225
+ * @param {string[]} lines
226
+ * @param {string} lang
227
+ * @returns {string}
228
+ */
229
+ function formatCodeBlock(lines, lang) {
230
+ if (noColor) {
231
+ return lines.map(l => ` ${l}`).join('\n');
232
+ }
233
+
234
+ const maxLen = Math.max(...lines.map(l => l.length), 20);
235
+ const width = Math.min(maxLen + 4, (process.stdout.columns || 80) - 4);
236
+ const top = a(ANSI.gray, ` \u250C${'─'.repeat(width)}\u2510${lang ? ` ${lang}` : ''}`);
237
+ const bot = a(ANSI.gray, ` \u2514${'─'.repeat(width)}\u2518`);
238
+ const body = lines.map(l => {
239
+ const highlighted = highlightSyntax(l, lang);
240
+ return ` ${a(ANSI.gray, '\u2502')} ${highlighted}`;
241
+ });
242
+
243
+ return [top, ...body, bot].join('\n');
244
+ }
245
+
246
+ /**
247
+ * Format a markdown table.
248
+ * @param {string[]} rows
249
+ * @returns {string}
250
+ */
251
+ function formatTable(rows) {
252
+ const parsed = rows.map(r =>
253
+ r.split('|').slice(1, -1).map(c => c.trim())
254
+ );
255
+
256
+ if (parsed.length === 0) return '';
257
+
258
+ // Calculate column widths
259
+ const colCount = parsed[0].length;
260
+ const widths = [];
261
+ for (let c = 0; c < colCount; c++) {
262
+ widths.push(Math.max(...parsed.map(r => (r[c] || '').length)));
263
+ }
264
+
265
+ const formatted = parsed.map((row, ri) => {
266
+ const cells = row.map((cell, ci) => cell.padEnd(widths[ci] || 0));
267
+ const line = ` ${cells.join(' ')}`;
268
+ return ri === 0 ? a(ANSI.bold, line) : line;
269
+ });
270
+
271
+ // Add separator after header
272
+ if (formatted.length > 1) {
273
+ const sep = widths.map(w => '─'.repeat(w)).join('──');
274
+ formatted.splice(1, 0, a(ANSI.dim, ` ${sep}`));
275
+ }
276
+
277
+ return formatted.join('\n');
278
+ }