@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/README.md +113 -0
- package/package.json +20 -2
- package/src/config/env.mjs +0 -2
- package/src/config/settings.mjs +24 -130
- package/src/core/session.mjs +4 -145
- package/src/core/system-prompt.mjs +43 -47
- package/src/index.mjs +21 -31
- package/src/permissions/checker.mjs +8 -79
- package/src/tools/bash.mjs +3 -40
- package/src/ui/app.mjs +286 -0
- package/src/ui/commands.mjs +83 -70
- package/src/ui/components.mjs +219 -0
- package/src/ui/ink-app.mjs +13 -52
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/repl.mjs +10 -25
- package/test/test.mjs +0 -800
|
@@ -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
|
+
}
|
package/src/ui/ink-app.mjs
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
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)
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
// ANSI color codes
|
|
@@ -30,7 +29,6 @@ const COLORS = {
|
|
|
30
29
|
bgGreen: '\x1b[42m',
|
|
31
30
|
bgYellow: '\x1b[43m',
|
|
32
31
|
bgBlue: '\x1b[44m',
|
|
33
|
-
bgGray: '\x1b[48;5;236m',
|
|
34
32
|
};
|
|
35
33
|
|
|
36
34
|
const noColor = process.env.NO_COLOR === '1';
|
|
@@ -80,7 +78,6 @@ export class Spinner {
|
|
|
80
78
|
|
|
81
79
|
/**
|
|
82
80
|
* Highlight code blocks in markdown text.
|
|
83
|
-
* Detects ``` blocks and adds subtle background/syntax coloring.
|
|
84
81
|
* @param {string} text
|
|
85
82
|
* @returns {string}
|
|
86
83
|
*/
|
|
@@ -91,7 +88,7 @@ export function highlightCode(text) {
|
|
|
91
88
|
const header = lang ? c('dim', ` ${lang}`) : '';
|
|
92
89
|
const highlighted = code
|
|
93
90
|
.split('\n')
|
|
94
|
-
.map(line =>
|
|
91
|
+
.map(line => ` ${highlightLine(line, lang)}`)
|
|
95
92
|
.join('\n');
|
|
96
93
|
return `${c('dim', '```')}${header}\n${highlighted}\n${c('dim', '```')}`;
|
|
97
94
|
});
|
|
@@ -141,18 +138,16 @@ export function renderMarkdown(text) {
|
|
|
141
138
|
|
|
142
139
|
/**
|
|
143
140
|
* Render a tool progress indicator.
|
|
144
|
-
* Shows [ToolName] running... in yellow.
|
|
145
141
|
* @param {string} toolName
|
|
146
142
|
* @param {string} status
|
|
147
143
|
*/
|
|
148
144
|
export function renderToolProgress(toolName, status) {
|
|
149
145
|
const icon = status === 'running' ? c('yellow', '>>') : c('green', '>>');
|
|
150
|
-
return `${icon} ${c('yellow',
|
|
146
|
+
return `${icon} ${c('yellow', toolName)} ${c('dim', status)}`;
|
|
151
147
|
}
|
|
152
148
|
|
|
153
149
|
/**
|
|
154
150
|
* Render a status bar with token counts and cost estimate.
|
|
155
|
-
* Shown after each turn: (tokens: X in / Y out | $Z)
|
|
156
151
|
* @param {object} state
|
|
157
152
|
*/
|
|
158
153
|
export function renderStatusBar(state) {
|
|
@@ -161,59 +156,25 @@ export function renderStatusBar(state) {
|
|
|
161
156
|
const model = state.model || 'default';
|
|
162
157
|
const inp = state.tokenUsage?.input || 0;
|
|
163
158
|
const out = state.tokenUsage?.output || 0;
|
|
159
|
+
const tokens = `in:${inp} out:${out}`;
|
|
160
|
+
const turns = `turn:${state.turnCount || 0}`;
|
|
164
161
|
|
|
165
|
-
// Cost estimate
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
|
162
|
+
// Cost estimate (Sonnet pricing by default)
|
|
163
|
+
const costIn = (inp / 1_000_000) * 3;
|
|
164
|
+
const costOut = (out / 1_000_000) * 15;
|
|
175
165
|
const cost = `$${(costIn + costOut).toFixed(4)}`;
|
|
176
|
-
const turns = `turn:${state.turnCount || 0}`;
|
|
177
166
|
|
|
178
|
-
const
|
|
179
|
-
const right = `${model} | ${costDisplay} | ${turns}`;
|
|
167
|
+
const right = `${model} | ${tokens} | ${cost} | ${turns}`;
|
|
180
168
|
const padding = Math.max(0, cols - right.length - 1);
|
|
181
169
|
return c('dim', `${' '.repeat(padding)}${right}`);
|
|
182
170
|
}
|
|
183
171
|
|
|
184
172
|
/**
|
|
185
|
-
*
|
|
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.
|
|
173
|
+
* Render an error message.
|
|
208
174
|
* @param {string} message
|
|
209
|
-
* @param {string} [context]
|
|
210
175
|
*/
|
|
211
|
-
export function renderError(message
|
|
212
|
-
|
|
213
|
-
if (context) {
|
|
214
|
-
result += `\n${c('dim', ` Context: ${context}`)}`;
|
|
215
|
-
}
|
|
216
|
-
return result;
|
|
176
|
+
export function renderError(message) {
|
|
177
|
+
return `${c('red', 'Error:')} ${message}`;
|
|
217
178
|
}
|
|
218
179
|
|
|
219
180
|
/**
|
|
@@ -233,11 +194,11 @@ export function renderSuccess(message) {
|
|
|
233
194
|
}
|
|
234
195
|
|
|
235
196
|
/**
|
|
236
|
-
* Render thinking output (dim
|
|
197
|
+
* Render thinking output (dim).
|
|
237
198
|
* @param {string} text
|
|
238
199
|
*/
|
|
239
200
|
export function renderThinking(text) {
|
|
240
|
-
return c('
|
|
201
|
+
return c('dim', text);
|
|
241
202
|
}
|
|
242
203
|
|
|
243
204
|
/**
|
|
@@ -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
|
+
}
|