@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 +113 -0
- package/package.json +20 -2
- package/src/index.mjs +16 -4
- package/src/ui/app.mjs +286 -0
- package/src/ui/components.mjs +219 -0
- package/src/ui/markdown.mjs +278 -0
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.
|
|
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":
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|