osai-agent 4.0.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/LICENSE +7 -0
- package/package.json +72 -0
- package/src/agent/context.js +141 -0
- package/src/agent/loop/context-summary.js +196 -0
- package/src/agent/loop/directory-utils.js +102 -0
- package/src/agent/loop/local.js +196 -0
- package/src/agent/loop/loop-detection.js +288 -0
- package/src/agent/loop/stream-parser.js +515 -0
- package/src/agent/loop/tool-executor.js +470 -0
- package/src/agent/loop/verification.js +263 -0
- package/src/agent/loop/websocket.js +80 -0
- package/src/agent/prompt.js +259 -0
- package/src/agent/react-loop.js +697 -0
- package/src/agent/subagent.js +263 -0
- package/src/commands/config.js +53 -0
- package/src/commands/connect.js +190 -0
- package/src/commands/devices.js +121 -0
- package/src/commands/login.js +77 -0
- package/src/commands/logout.js +31 -0
- package/src/commands/mcp.js +258 -0
- package/src/commands/provider.js +633 -0
- package/src/commands/register.js +74 -0
- package/src/commands/run.js +150 -0
- package/src/commands/search.js +64 -0
- package/src/commands/session.js +57 -0
- package/src/commands/skills.js +54 -0
- package/src/commands/stop-subagent.js +58 -0
- package/src/index.js +208 -0
- package/src/llm/direct.js +317 -0
- package/src/memory/store.js +215 -0
- package/src/mock-readline.js +27 -0
- package/src/parser/dependencies.js +71 -0
- package/src/parser/markdown.js +505 -0
- package/src/parser/stream.js +96 -0
- package/src/prompts/modes/CODING.js +160 -0
- package/src/prompts/modes/GENERAL.js +105 -0
- package/src/prompts/modes/NETWORK.js +69 -0
- package/src/prompts/modes/SSH.js +53 -0
- package/src/prompts/systemPrompt.js +85 -0
- package/src/safety/check.js +210 -0
- package/src/services/crypto.js +78 -0
- package/src/services/executor.js +68 -0
- package/src/services/history.js +58 -0
- package/src/services/server-url.js +11 -0
- package/src/services/session.js +194 -0
- package/src/services/ssh.js +176 -0
- package/src/services/websocket.js +112 -0
- package/src/skills/loader.js +231 -0
- package/src/tools/browser.js +434 -0
- package/src/tools/local.js +1254 -0
- package/src/tools/mcp-client.js +209 -0
- package/src/tools/registry.js +132 -0
- package/src/tools/search-providers.js +237 -0
- package/src/tools/ssh.js +74 -0
- package/src/ui/App.js +2031 -0
- package/src/ui/animation.js +47 -0
- package/src/ui/components/AskUserDialog.js +33 -0
- package/src/ui/components/ConfirmationDialog.js +45 -0
- package/src/ui/components/DiffView.js +201 -0
- package/src/ui/components/Header.js +157 -0
- package/src/ui/components/HistoryPicker.js +130 -0
- package/src/ui/components/InputShell.js +22 -0
- package/src/ui/components/MessageHistory.js +1200 -0
- package/src/ui/components/ModalPanel.js +40 -0
- package/src/ui/components/ModePicker.js +161 -0
- package/src/ui/components/PlanDialog.js +48 -0
- package/src/ui/components/ProviderMenu.js +1095 -0
- package/src/ui/components/SavePicker.js +106 -0
- package/src/ui/components/SelectMenu.js +194 -0
- package/src/ui/components/SlashMenu.js +168 -0
- package/src/ui/components/SubagentPanel.js +138 -0
- package/src/ui/components/TextInputSafe.js +117 -0
- package/src/ui/components/TodoPanel.js +54 -0
- package/src/ui/components/ToolExecution.js +261 -0
- package/src/ui/components/TranscriptViewport.js +99 -0
- package/src/ui/diff.js +249 -0
- package/src/ui/h.js +7 -0
- package/src/ui/mouse-scroll.js +63 -0
- package/src/ui/slash-picker.js +58 -0
- package/src/ui/terminal.js +41 -0
- package/src/ui/theme.js +5 -0
- package/src/ui/welcome.js +12 -0
- package/src/utils/constants.js +231 -0
- package/src/utils/helpers.js +154 -0
- package/src/utils/logger.js +81 -0
- package/src/utils/sound.js +33 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// OS AI Agent — Markdown Renderer v4.0
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Two modes:
|
|
5
|
+
// renderMarkdown(text) — full buffered render (après réception complète)
|
|
6
|
+
// StreamMarkdown — classe stateful pour le streaming token-by-token
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
// ─── Tokyo Night color tokens ─────────────────────────────────
|
|
12
|
+
const C = {
|
|
13
|
+
text: '#c0caf5',
|
|
14
|
+
bright: '#e2e8f0',
|
|
15
|
+
blue: '#7aa2f7',
|
|
16
|
+
cyan: '#7dcfff',
|
|
17
|
+
teal: '#2dd4bf',
|
|
18
|
+
green: '#9ece6a',
|
|
19
|
+
emerald: '#34d399',
|
|
20
|
+
amber: '#e0af68',
|
|
21
|
+
red: '#f7768e',
|
|
22
|
+
soft: '#9aa5ce',
|
|
23
|
+
subtle: '#565f89',
|
|
24
|
+
dim: '#414868',
|
|
25
|
+
muted: '#3b3f52',
|
|
26
|
+
surface: '#1a1b26',
|
|
27
|
+
border: '#2a2e3f',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ELLIPSIS = '…';
|
|
31
|
+
const FORCE_ASCII_TABLES =
|
|
32
|
+
process.env.OSAI_TABLE_ASCII === '1' ||
|
|
33
|
+
process.env.NO_UNICODE_TABLES === '1' ||
|
|
34
|
+
(process.env.TERM || '').toLowerCase() === 'dumb';
|
|
35
|
+
|
|
36
|
+
// ─── Tool names (for stripping tool JSON from output) ────────
|
|
37
|
+
const ALL_TOOLS = 'LOCAL_CMD|SSH_CMD|READ_FILE|WRITE_FILE|EDIT_FILE|APPEND_FILE|DELETE_FILE|LIST_DIR|SEARCH_FILE|CREATE_DIR|TREE_VIEW|RUN_SCRIPT|MOVE_FILE|COPY_FILE|FILE_INFO|FETCH_URL|WEB_SEARCH|TODO_ADD|TODO_COMPLETE|TODO_UPDATE|TODO_LIST|TODO_CLEAR|POWERSHELL|GLOB|GREP|GIT|DIAG_POST_EDIT|ASK_USER|PLAN_MODE|SKILL_LIST|LOAD_SKILL|CREATE_SKILL|TASK';
|
|
38
|
+
|
|
39
|
+
const stripAnsi = (str) => (str || '')
|
|
40
|
+
.replace(/\x1b\[[0-9;]*m/g, '')
|
|
41
|
+
.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, '')
|
|
42
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
43
|
+
|
|
44
|
+
const visibleLen = (str) => stripAnsi(str).length;
|
|
45
|
+
|
|
46
|
+
export const stripToolJSON = (text) => {
|
|
47
|
+
if (!text) return '';
|
|
48
|
+
return text
|
|
49
|
+
.replace(new RegExp(`\\{[^{}]*"tool"\\s*:\\s*"(?:${ALL_TOOLS})"[^{}]*\\}`, 'g'), '')
|
|
50
|
+
.replace(new RegExp(`\\{[\\s\\S]*?"tool"\\s*:\\s*"(?:${ALL_TOOLS})"[\\s\\S]*?\\}`, 'g'), '')
|
|
51
|
+
.replace(/```(?:json)?\s*\{[\s\S]*?"tool"[\s\S]*?\}\s*```/g, '')
|
|
52
|
+
.replace(/\[TOOL_RESULT\][\s\S]*?(?=\n\n|\n[A-Z\u00C0-\u024F]|$)/g, '')
|
|
53
|
+
.replace(/\[DONE\]/gi, '').replace(/\[INCOMPLETE\]/gi, '').replace(/\[BLOCKED\]/gi, '')
|
|
54
|
+
.replace(/\[TOOL_CALL\]\s*/g, '')
|
|
55
|
+
.replace(/THE TOOL\s*CALL[\s\S]*?(?=\n\n|$)/gi, '')
|
|
56
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
57
|
+
.trim();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const stripToolXML = (text) => {
|
|
61
|
+
if (!text) return '';
|
|
62
|
+
return text
|
|
63
|
+
.replace(/<tool\b[^>]*>[\s\S]*?<\/tool\s*>/g, '')
|
|
64
|
+
.replace(/<tool\b[^>]*\/>/g, '')
|
|
65
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
66
|
+
.trim();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ─── Inline style renderer ────────────────────────────────────
|
|
70
|
+
export const renderInline = (text) => {
|
|
71
|
+
// Strip any raw ANSI escape sequences the LLM may have included in its output
|
|
72
|
+
const clean = text.replace(/\x1b\[[0-9;]*[mA-Za-z]/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '');
|
|
73
|
+
return clean
|
|
74
|
+
.replace(/\*\*\*([^*]+)\*\*\*/g, (_, t) => chalk.bold.italic.hex(C.bright)(t))
|
|
75
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, t) => chalk.bold.hex(C.bright)(t))
|
|
76
|
+
.replace(/\*([^*\n]+)\*/g, (_, t) => chalk.italic.hex(C.soft)(t))
|
|
77
|
+
.replace(/`([^`\n]+)`/g, (_, t) => chalk.bgHex(C.surface).hex(C.cyan)(` ${t} `))
|
|
78
|
+
.replace(/~~([^~]+)~~/g, (_, t) => chalk.strikethrough.hex(C.subtle)(t))
|
|
79
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
|
|
80
|
+
chalk.underline.hex(C.blue)(label) + chalk.hex(C.dim)(` (${url})`)
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const splitTableRow = (row) => {
|
|
85
|
+
const s = String(row || '').trim();
|
|
86
|
+
if (!s.includes('|')) return [s];
|
|
87
|
+
const src = s.startsWith('|') ? s.slice(1) : s;
|
|
88
|
+
const body = src.endsWith('|') ? src.slice(0, -1) : src;
|
|
89
|
+
return body.split('|').map(c => c.trim());
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const isMarkdownTableLine = (line) => {
|
|
93
|
+
const t = String(line || '').trim();
|
|
94
|
+
if (!t.includes('|')) return false;
|
|
95
|
+
const cells = splitTableRow(t);
|
|
96
|
+
return cells.length >= 2;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const isTableSeparatorLine = (line) => {
|
|
100
|
+
const cells = splitTableRow(line).filter(c => c.length > 0);
|
|
101
|
+
if (!cells.length) return false;
|
|
102
|
+
return cells.every(c => /^:?\s*-{3,}\s*:?$/.test(c));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const padAnsi = (text, width) => {
|
|
106
|
+
const pad = Math.max(0, width - visibleLen(text));
|
|
107
|
+
return text + ' '.repeat(pad);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const renderTableRows = (rows, maxWidth = 96) => {
|
|
111
|
+
if (!rows || !rows.length) return [];
|
|
112
|
+
const parsed = rows.map(r => splitTableRow(r));
|
|
113
|
+
const nonSep = parsed.filter((_, idx) => !isTableSeparatorLine(rows[idx]));
|
|
114
|
+
if (!nonSep.length) return [];
|
|
115
|
+
|
|
116
|
+
const colCount = Math.max(...nonSep.map(r => r.length));
|
|
117
|
+
const normalized = parsed.map(r => {
|
|
118
|
+
const out = r.slice(0, colCount);
|
|
119
|
+
while (out.length < colCount) out.push('');
|
|
120
|
+
return out;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const useUnicode = !FORCE_ASCII_TABLES;
|
|
124
|
+
const colSep = useUnicode ? ' │ ' : ' | ';
|
|
125
|
+
const rowSep = useUnicode ? '─┼─' : '-+-';
|
|
126
|
+
const rowFill = useUnicode ? '─' : '-';
|
|
127
|
+
const maxColWidth = Math.max(8, Math.floor((Math.max(60, maxWidth) - (colCount - 1) * colSep.length - 2) / colCount));
|
|
128
|
+
const widths = Array.from({ length: colCount }, () => 3);
|
|
129
|
+
for (const r of normalized) {
|
|
130
|
+
for (let i = 0; i < colCount; i++) {
|
|
131
|
+
const len = visibleLen(stripAnsi(renderInline(r[i] || '')));
|
|
132
|
+
widths[i] = Math.min(maxColWidth, Math.max(widths[i], len));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const widthBudget = Math.max(24, maxWidth - 2);
|
|
137
|
+
const totalWidth = () => widths.reduce((a, b) => a + b, 0) + (colCount - 1) * colSep.length + 2;
|
|
138
|
+
const minColWidth = 3;
|
|
139
|
+
while (totalWidth() > widthBudget) {
|
|
140
|
+
let idx = -1;
|
|
141
|
+
let best = -1;
|
|
142
|
+
for (let i = 0; i < widths.length; i++) {
|
|
143
|
+
if (widths[i] > minColWidth && widths[i] > best) { best = widths[i]; idx = i; }
|
|
144
|
+
}
|
|
145
|
+
if (idx < 0) break;
|
|
146
|
+
widths[idx] -= 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const fitCell = (raw, width) => {
|
|
150
|
+
const plain = stripAnsi(renderInline(raw || ''));
|
|
151
|
+
if (plain.length <= width) return plain;
|
|
152
|
+
if (width <= 1) return plain.slice(0, width);
|
|
153
|
+
return plain.slice(0, width - 1) + ELLIPSIS;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const out = [];
|
|
157
|
+
let sawHeader = false;
|
|
158
|
+
for (let rowIdx = 0; rowIdx < normalized.length; rowIdx++) {
|
|
159
|
+
const raw = rows[rowIdx];
|
|
160
|
+
if (isTableSeparatorLine(raw)) {
|
|
161
|
+
const sep = widths.map(w => rowFill.repeat(Math.max(3, w))).join(chalk.hex(C.border)(rowSep));
|
|
162
|
+
out.push(chalk.hex(C.border)(' ' + sep));
|
|
163
|
+
sawHeader = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const r = normalized[rowIdx];
|
|
168
|
+
const cells = r.map((cell, i) => {
|
|
169
|
+
const fitted = fitCell(cell || '', widths[i]);
|
|
170
|
+
const styled = !sawHeader && rowIdx === 0 ? chalk.bold.hex(C.cyan)(fitted) : chalk.hex(C.text)(fitted);
|
|
171
|
+
return padAnsi(styled, widths[i]);
|
|
172
|
+
});
|
|
173
|
+
out.push(' ' + cells.join(chalk.hex(C.border)(colSep)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return out;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ─── Syntax highlighter ───────────────────────────────────────
|
|
180
|
+
const KEYWORDS_JS = /\b(import|export|from|const|let|var|function|async|await|return|if|else|for|while|class|extends|new|try|catch|finally|throw|typeof|instanceof|null|undefined|true|false|void|switch|case|break|continue|default|of|in|do|yield|get|set|static|super)\b/g;
|
|
181
|
+
const KEYWORDS_SH = /\b(echo|sudo|apt|npm|pip|git|cd|ls|mkdir|rm|mv|cp|chmod|chown|systemctl|service|grep|awk|sed|cat|tail|head|curl|wget|ssh|scp|export|source|alias|if|then|fi|else|for|do|done|while|case|esac)\b/g;
|
|
182
|
+
const KEYWORDS_PY = /\b(def|class|import|from|as|return|if|elif|else|for|while|try|except|finally|with|lambda|yield|pass|break|continue|not|and|or|in|is|None|True|False|print|len|range)\b/g;
|
|
183
|
+
|
|
184
|
+
export const highlightCode = (code, lang = '') => {
|
|
185
|
+
const l = lang.toLowerCase();
|
|
186
|
+
return code.split('\n').map(line => {
|
|
187
|
+
let out = line
|
|
188
|
+
// Strings
|
|
189
|
+
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g,
|
|
190
|
+
(_, s) => chalk.hex(C.green)(s))
|
|
191
|
+
// Numbers
|
|
192
|
+
.replace(/\b(\d+\.?\d*)\b/g, (_, n) => chalk.hex(C.amber)(n))
|
|
193
|
+
// Comments
|
|
194
|
+
.replace(/(\/\/.*|#.*)$/, (_, c) => chalk.hex(C.subtle)(c))
|
|
195
|
+
.replace(/(\/\*[\s\S]*?\*\/)/, (_, c) => chalk.hex(C.subtle)(c));
|
|
196
|
+
|
|
197
|
+
if (l === 'py' || l === 'python') {
|
|
198
|
+
out = out.replace(KEYWORDS_PY, (_, kw) => chalk.hex(C.blue)(kw));
|
|
199
|
+
} else if (l === 'sh' || l === 'bash' || l === 'shell' || l === 'zsh') {
|
|
200
|
+
out = out.replace(KEYWORDS_SH, (_, kw) => chalk.hex(C.cyan)(kw));
|
|
201
|
+
} else {
|
|
202
|
+
// JS/TS/JSX/TSX/JSON/default
|
|
203
|
+
out = out.replace(KEYWORDS_JS, (_, kw) => chalk.hex(C.blue)(kw));
|
|
204
|
+
out = out.replace(KEYWORDS_SH, (_, kw) => chalk.hex(C.cyan)(kw));
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}).join('\n');
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// ─── Full buffered markdown renderer ─────────────────────────
|
|
211
|
+
export const renderMarkdown = (text) => {
|
|
212
|
+
// Strip raw ANSI sequences the LLM may have embedded in its output
|
|
213
|
+
const deAnsi = (text || '').replace(/\x1b\[[0-9;]*[mA-Za-z]/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '');
|
|
214
|
+
const normalizedStructure = deAnsi
|
|
215
|
+
// ---### + optional text → big white dot divider (must precede HR+heading fusion)
|
|
216
|
+
.replace(/(^|\n)\s*-{3,}\s*#{3}\s*([^\n]*)/g, '$1__BIGDOT__$2')
|
|
217
|
+
// Fix fused horizontal-rule + heading, e.g. "---## Title" -> "## Title"
|
|
218
|
+
.replace(/(^|\n)\s*(?:-{3,}|\*{3,}|_{3,})\s*(#{1,4})\s*/g, '$1$2 ')
|
|
219
|
+
// Fix collapsed headings without space, e.g. "###Title" -> "### Title"
|
|
220
|
+
.replace(/(^|\n)(#{1,4})([^#\s\n])/g, '$1$2 $3');
|
|
221
|
+
const clean = stripToolXML(stripToolJSON(normalizedStructure));
|
|
222
|
+
if (!clean) return '';
|
|
223
|
+
|
|
224
|
+
const lines = clean.split('\n');
|
|
225
|
+
const output = [];
|
|
226
|
+
let inCode = false, codeLang = '', codeLines = [];
|
|
227
|
+
let inTable = false, tableRows = [];
|
|
228
|
+
const w = Math.min(process.stdout.columns || 96, 100);
|
|
229
|
+
|
|
230
|
+
const flushTable = () => {
|
|
231
|
+
if (!tableRows.length) return;
|
|
232
|
+
output.push(...renderTableRows(tableRows, w));
|
|
233
|
+
output.push('');
|
|
234
|
+
tableRows = []; inTable = false;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
for (const rawLine of lines) {
|
|
238
|
+
// Code block toggle
|
|
239
|
+
const codeMatch = rawLine.match(/^```(\w*)/);
|
|
240
|
+
if (codeMatch) {
|
|
241
|
+
if (!inCode) {
|
|
242
|
+
inCode = true; codeLang = codeMatch[1] || ''; codeLines = [];
|
|
243
|
+
const label = codeLang ? chalk.bgHex(C.border).hex(C.cyan)(` ${codeLang} `) : '';
|
|
244
|
+
output.push(chalk.hex(C.border)(' ╭─') + (label || chalk.hex(C.border)('─')));
|
|
245
|
+
} else {
|
|
246
|
+
const highlighted = highlightCode(codeLines.join('\n'), codeLang);
|
|
247
|
+
for (const cl of highlighted.split('\n'))
|
|
248
|
+
output.push(chalk.hex(C.border)(' │ ') + cl);
|
|
249
|
+
output.push(chalk.hex(C.border)(' ╰────'));
|
|
250
|
+
inCode = false; codeLines = []; codeLang = '';
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (inCode) { codeLines.push(rawLine); continue; }
|
|
255
|
+
|
|
256
|
+
// Table
|
|
257
|
+
if (isMarkdownTableLine(rawLine)) {
|
|
258
|
+
inTable = true; tableRows.push(rawLine.trim()); continue;
|
|
259
|
+
} else if (inTable) flushTable();
|
|
260
|
+
|
|
261
|
+
// Big white dot divider: ---### [optional text]
|
|
262
|
+
const bigDot = rawLine.match(/(?:^\s*-{3,}\s*#{3}|^__BIGDOT__)\s*(.*)/);
|
|
263
|
+
if (bigDot) {
|
|
264
|
+
const text = bigDot[1].trim();
|
|
265
|
+
output.push('');
|
|
266
|
+
output.push(chalk.bold.hex(C.bright)(' ●') + (text ? ' ' + chalk.hex(C.bright)(renderInline(text)) : ''));
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Headings
|
|
271
|
+
const h1 = rawLine.match(/^# ?(.+)/);
|
|
272
|
+
if (h1) { output.push(''); output.push(chalk.bold.hex(C.blue)(' ━━ ') + chalk.bold.hex(C.bright)(renderInline(h1[1]))); output.push(chalk.hex(C.border)(' ' + '─'.repeat(Math.min(w - 4, 58)))); continue; }
|
|
273
|
+
const h2 = rawLine.match(/^## ?(.+)/);
|
|
274
|
+
if (h2) { output.push(''); output.push(chalk.bold.hex(C.cyan)(' ◆ ') + chalk.bold.hex(C.text)(renderInline(h2[1]))); continue; }
|
|
275
|
+
const h3 = rawLine.match(/^### ?(.+)/);
|
|
276
|
+
if (h3) { output.push(''); output.push(chalk.bold.hex(C.teal)(' ● ') + chalk.bold.hex(C.soft)(renderInline(h3[1]))); continue; }
|
|
277
|
+
const h4 = rawLine.match(/^#### ?(.+)/);
|
|
278
|
+
if (h4) { output.push(chalk.hex(C.subtle)(' › ') + chalk.italic.hex(C.soft)(renderInline(h4[1]))); continue; }
|
|
279
|
+
|
|
280
|
+
// HR
|
|
281
|
+
if (rawLine.match(/^[-*_]{3,}\s*$/)) { output.push(chalk.hex(C.border)(' ' + '─'.repeat(Math.min(w - 4, 60)))); continue; }
|
|
282
|
+
|
|
283
|
+
// Blockquote
|
|
284
|
+
const bq = rawLine.match(/^>\s*(.*)/);
|
|
285
|
+
if (bq) { output.push(chalk.hex(C.border)(' ┃ ') + chalk.italic.hex(C.soft)(renderInline(bq[1]))); continue; }
|
|
286
|
+
|
|
287
|
+
// Lists
|
|
288
|
+
const num = rawLine.match(/^(\s*)(\d+)\.\s+(.*)/);
|
|
289
|
+
if (num) { const [, ind, n, c] = num; const lv = Math.floor(ind.length / 2); output.push(' '.repeat(lv + 1) + chalk.hex(C.blue)(n + '.') + ' ' + renderInline(c)); continue; }
|
|
290
|
+
const ul = rawLine.match(/^(\s*)[-*•]\s+(.*)/);
|
|
291
|
+
if (ul) { const [, ind, c] = ul; const lv = Math.floor(ind.length / 2); const b = lv === 0 ? chalk.hex(C.emerald)('•') : lv === 1 ? chalk.hex(C.cyan)('◦') : chalk.hex(C.subtle)('▸'); output.push(' '.repeat(lv + 1) + b + ' ' + renderInline(c)); continue; }
|
|
292
|
+
|
|
293
|
+
// Empty line
|
|
294
|
+
if (!rawLine.trim()) { output.push(''); continue; }
|
|
295
|
+
|
|
296
|
+
// Normal text
|
|
297
|
+
output.push(' ' + renderInline(rawLine));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (inTable) flushTable();
|
|
301
|
+
if (inCode && codeLines.length > 0) {
|
|
302
|
+
for (const cl of codeLines) output.push(chalk.hex(C.border)(' │ ') + chalk.hex(C.text)(cl));
|
|
303
|
+
output.push(chalk.hex(C.border)(' ╰────'));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return output.join('\n');
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// ─── StreamMarkdown — stateful streaming renderer ─────────────
|
|
310
|
+
// Usage:
|
|
311
|
+
// const sm = new StreamMarkdown();
|
|
312
|
+
// sm.push(chunk) → returns string to print immediately
|
|
313
|
+
// sm.flush() → returns remaining buffered content
|
|
314
|
+
// sm.reset() → clears state for next message
|
|
315
|
+
//
|
|
316
|
+
// Handles:
|
|
317
|
+
// - Code blocks accumulate until closing ``` then render fully
|
|
318
|
+
// - Bold/italic rendered when markers are complete (no half-markers)
|
|
319
|
+
// - Headings, lists, blockquotes rendered line-by-line
|
|
320
|
+
// - Tool JSON swallowed silently
|
|
321
|
+
|
|
322
|
+
export class StreamMarkdown {
|
|
323
|
+
constructor() { this.reset(); }
|
|
324
|
+
|
|
325
|
+
reset() {
|
|
326
|
+
this._buf = ''; // incomplete current line buffer
|
|
327
|
+
this._inCode = false;
|
|
328
|
+
this._codeLang = '';
|
|
329
|
+
this._codeLines = [];
|
|
330
|
+
this._codeHeader = ''; // the ╭─ line printed when code starts
|
|
331
|
+
this._jsonBuf = '';
|
|
332
|
+
this._inJson = false;
|
|
333
|
+
this._jsonDepth = 0;
|
|
334
|
+
this._xmlToolBuf = '';
|
|
335
|
+
this._inXmlTool = false;
|
|
336
|
+
this._inTable = false;
|
|
337
|
+
this._tableRows = [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
push(chunk) {
|
|
341
|
+
// Swallow tool JSON silently
|
|
342
|
+
let out = '';
|
|
343
|
+
// Normalize "###Title" → "### Title" and "##Title" → "## Title" before buffering
|
|
344
|
+
const normalized = chunk
|
|
345
|
+
// ---### + optional text → big white dot divider (must precede HR+heading fusion)
|
|
346
|
+
.replace(/(^|\n)\s*-{3,}\s*#{3}\s*([^\n]*)/g, '$1__BIGDOT__$2')
|
|
347
|
+
// Fix fused HR + heading in stream chunks: "---## Title" -> "## Title"
|
|
348
|
+
.replace(/(^|\n)\s*(?:-{3,}|\*{3,}|_{3,})\s*(#{1,4})\s*/g, '$1$2 ')
|
|
349
|
+
// Fix collapsed heading spacing
|
|
350
|
+
.replace(/(^|\n)(#{1,4})([^#\s\n])/g, '$1$2 $3');
|
|
351
|
+
this._buf += normalized;
|
|
352
|
+
|
|
353
|
+
// Process complete lines
|
|
354
|
+
const lines = this._buf.split('\n');
|
|
355
|
+
this._buf = lines.pop() ?? '';
|
|
356
|
+
|
|
357
|
+
for (const line of lines) out += this._processLine(line + '\n');
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
flush() {
|
|
362
|
+
let out = '';
|
|
363
|
+
if (this._buf.trim() && this._inTable && isMarkdownTableLine(this._buf)) {
|
|
364
|
+
this._tableRows.push(this._buf.trim());
|
|
365
|
+
this._buf = '';
|
|
366
|
+
}
|
|
367
|
+
if (this._inTable && this._tableRows.length) {
|
|
368
|
+
out += this._flushTable();
|
|
369
|
+
}
|
|
370
|
+
if (this._inCode && this._buf) {
|
|
371
|
+
this._codeLines.push(this._buf);
|
|
372
|
+
this._buf = '';
|
|
373
|
+
}
|
|
374
|
+
if (this._inCode && this._codeLines.length) {
|
|
375
|
+
// Close unclosed code block
|
|
376
|
+
const highlighted = highlightCode(this._codeLines.join('\n'), this._codeLang);
|
|
377
|
+
for (const cl of highlighted.split('\n'))
|
|
378
|
+
out += chalk.hex(C.border)(' │ ') + cl + '\n';
|
|
379
|
+
out += chalk.hex(C.border)(' ╰────') + '\n';
|
|
380
|
+
} else if (this._buf.trim()) {
|
|
381
|
+
out += this._renderLine(this._buf);
|
|
382
|
+
}
|
|
383
|
+
this.reset();
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_processLine(line) {
|
|
388
|
+
const raw = line.replace(/\n$/, '');
|
|
389
|
+
|
|
390
|
+
if (this._inTable && !isMarkdownTableLine(raw)) {
|
|
391
|
+
const rendered = this._flushTable();
|
|
392
|
+
if (!raw.trim()) return rendered;
|
|
393
|
+
return rendered + this._renderLine(raw) + '\n';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Tool JSON detection — swallow silently
|
|
397
|
+
if (this._inJson) {
|
|
398
|
+
this._jsonBuf += raw;
|
|
399
|
+
this._jsonDepth += (raw.match(/{/g)||[]).length - (raw.match(/}/g)||[]).length;
|
|
400
|
+
if (this._jsonDepth <= 0) { this._inJson = false; this._jsonBuf = ''; }
|
|
401
|
+
return '';
|
|
402
|
+
}
|
|
403
|
+
if (raw.trim().match(/^\{"tool"\s*:/)) {
|
|
404
|
+
this._inJson = true;
|
|
405
|
+
this._jsonBuf = raw;
|
|
406
|
+
this._jsonDepth = (raw.match(/{/g)||[]).length - (raw.match(/}/g)||[]).length;
|
|
407
|
+
if (this._jsonDepth <= 0) { this._inJson = false; this._jsonBuf = ''; }
|
|
408
|
+
return '';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Tool XML detection — swallow silently
|
|
412
|
+
if (this._inXmlTool) {
|
|
413
|
+
this._xmlToolBuf += raw;
|
|
414
|
+
if (raw.includes('/>') || raw.includes('</tool>')) { this._inXmlTool = false; this._xmlToolBuf = ''; }
|
|
415
|
+
return '';
|
|
416
|
+
}
|
|
417
|
+
if (raw.trim().startsWith('<tool') && (raw.includes('/>') || raw.includes('</tool>'))) {
|
|
418
|
+
return '';
|
|
419
|
+
}
|
|
420
|
+
if (raw.trim().startsWith('<tool')) {
|
|
421
|
+
this._inXmlTool = true;
|
|
422
|
+
this._xmlToolBuf = raw;
|
|
423
|
+
return '';
|
|
424
|
+
}
|
|
425
|
+
// Strip completion signals
|
|
426
|
+
if (/^\s*\[(DONE|INCOMPLETE|BLOCKED|TOOL_CALL|TOOL_RESULT)\]/.test(raw)) return '';
|
|
427
|
+
|
|
428
|
+
// Code block
|
|
429
|
+
const codeMatch = raw.match(/^```(\w*)/);
|
|
430
|
+
if (codeMatch) {
|
|
431
|
+
if (!this._inCode) {
|
|
432
|
+
this._inCode = true; this._codeLang = codeMatch[1] || ''; this._codeLines = [];
|
|
433
|
+
const label = this._codeLang ? chalk.bgHex(C.border).hex(C.cyan)(` ${this._codeLang} `) : '';
|
|
434
|
+
return chalk.hex(C.border)(' ╭─') + (label || chalk.hex(C.border)('─')) + '\n';
|
|
435
|
+
} else {
|
|
436
|
+
const highlighted = highlightCode(this._codeLines.join('\n'), this._codeLang);
|
|
437
|
+
let out = '';
|
|
438
|
+
for (const cl of highlighted.split('\n')) out += chalk.hex(C.border)(' │ ') + cl + '\n';
|
|
439
|
+
out += chalk.hex(C.border)(' ╰────') + '\n';
|
|
440
|
+
this._inCode = false; this._codeLines = []; this._codeLang = '';
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (this._inCode) { this._codeLines.push(raw); return ''; }
|
|
445
|
+
|
|
446
|
+
if (isMarkdownTableLine(raw)) {
|
|
447
|
+
this._inTable = true;
|
|
448
|
+
this._tableRows.push(raw.trim());
|
|
449
|
+
return '';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return this._renderLine(raw) + '\n';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_flushTable() {
|
|
456
|
+
if (!this._tableRows.length) {
|
|
457
|
+
this._inTable = false;
|
|
458
|
+
return '';
|
|
459
|
+
}
|
|
460
|
+
const w = Math.min(process.stdout.columns || 96, 100);
|
|
461
|
+
const lines = renderTableRows(this._tableRows, w);
|
|
462
|
+
this._tableRows = [];
|
|
463
|
+
this._inTable = false;
|
|
464
|
+
return lines.length ? lines.join('\n') + '\n' : '';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_renderLine(line) {
|
|
468
|
+
if (!line.trim()) return '';
|
|
469
|
+
|
|
470
|
+
// Big white dot divider: ---### [optional text]
|
|
471
|
+
const bigDot = line.match(/(?:^\s*-{3,}\s*#{3}|^__BIGDOT__)\s*(.*)/);
|
|
472
|
+
if (bigDot) {
|
|
473
|
+
const text = bigDot[1].trim();
|
|
474
|
+
return '\n' + chalk.bold.hex(C.bright)(' ●') + (text ? ' ' + chalk.hex(C.bright)(renderInline(text)) : '') + '\n';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Headings — support both "## Title" and "##Title" (no space)
|
|
478
|
+
const h1 = line.match(/^# ?(.+)/); if (h1) return '\n' + chalk.bold.hex(C.blue)(' ━━ ') + chalk.bold.hex(C.bright)(renderInline(h1[1])) + '\n' + chalk.hex(C.border)(' ' + '─'.repeat(56));
|
|
479
|
+
const h2 = line.match(/^## ?(.+)/); if (h2) return '\n' + chalk.bold.hex(C.cyan)(' ◆ ') + chalk.bold(renderInline(h2[1]));
|
|
480
|
+
const h3 = line.match(/^### ?(.+)/); if (h3) return '\n' + chalk.bold.hex(C.teal)(' ● ') + chalk.hex(C.soft)(renderInline(h3[1]));
|
|
481
|
+
const h4 = line.match(/^#### ?(.+)/); if (h4) return chalk.hex(C.subtle)(' › ') + chalk.italic.hex(C.soft)(renderInline(h4[1]));
|
|
482
|
+
|
|
483
|
+
// HR
|
|
484
|
+
if (line.match(/^[-*_]{3,}\s*$/)) return chalk.hex(C.border)(' ' + '─'.repeat(56));
|
|
485
|
+
|
|
486
|
+
// Blockquote
|
|
487
|
+
const bq = line.match(/^>\s*(.*)/); if (bq) return chalk.hex(C.border)(' ┃ ') + chalk.italic.hex(C.soft)(renderInline(bq[1]));
|
|
488
|
+
|
|
489
|
+
// Lists
|
|
490
|
+
const num = line.match(/^(\s*)(\d+)\.\s+(.*)/);
|
|
491
|
+
if (num) { const [, ind, n, c] = num; const lv = Math.floor(ind.length / 2); return ' '.repeat(lv + 1) + chalk.hex(C.blue)(n + '.') + ' ' + renderInline(c); }
|
|
492
|
+
const ul = line.match(/^(\s*)[-*•]\s+(.*)/);
|
|
493
|
+
if (ul) { const [, ind, c] = ul; const lv = Math.floor(ind.length / 2); const b = lv === 0 ? chalk.hex(C.emerald)('•') : lv === 1 ? chalk.hex(C.cyan)('◦') : chalk.hex(C.subtle)('▸'); return ' '.repeat(lv + 1) + b + ' ' + renderInline(c); }
|
|
494
|
+
|
|
495
|
+
return ' ' + renderInline(line);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ─── Legacy export (kept for compatibility) ───────────────────
|
|
500
|
+
export const renderMarkdownChunk = (chunk) => {
|
|
501
|
+
try {
|
|
502
|
+
const sm = new StreamMarkdown();
|
|
503
|
+
return sm.push(chunk) + sm.flush();
|
|
504
|
+
} catch { return chunk; }
|
|
505
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StreamParser — parses the raw SSE text stream from the server.
|
|
5
|
+
*
|
|
6
|
+
* Emits:
|
|
7
|
+
* 'tool_call' — a complete JSON tool call object
|
|
8
|
+
* 'markdown_chunk' — a string of markdown/text to render
|
|
9
|
+
*/
|
|
10
|
+
export class StreamParser extends EventEmitter {
|
|
11
|
+
constructor() {
|
|
12
|
+
super();
|
|
13
|
+
this.buffer = '';
|
|
14
|
+
this.jsonBuffer = '';
|
|
15
|
+
this.inJsonBlock = false;
|
|
16
|
+
this.braceDepth = 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process(chunk) {
|
|
20
|
+
this.buffer += chunk;
|
|
21
|
+
this._parseBuffer();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_parseBuffer() {
|
|
25
|
+
const lines = this.buffer.split('\n');
|
|
26
|
+
this.buffer = lines.pop() || '';
|
|
27
|
+
for (const line of lines) this._processLine(line);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_processLine(line) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
|
|
33
|
+
// Detect start of a JSON tool-call block
|
|
34
|
+
if (!this.inJsonBlock && trimmed.startsWith('{"tool"')) {
|
|
35
|
+
this.inJsonBlock = true;
|
|
36
|
+
this.jsonBuffer = trimmed;
|
|
37
|
+
this.braceDepth =
|
|
38
|
+
(trimmed.match(/{/g) || []).length - (trimmed.match(/}/g) || []).length;
|
|
39
|
+
if (this.braceDepth <= 0) {
|
|
40
|
+
this._tryFlushJson();
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.inJsonBlock) {
|
|
46
|
+
this.jsonBuffer += ' ' + trimmed;
|
|
47
|
+
this.braceDepth +=
|
|
48
|
+
(trimmed.match(/{/g) || []).length - (trimmed.match(/}/g) || []).length;
|
|
49
|
+
if (this.braceDepth <= 0) {
|
|
50
|
+
this._tryFlushJson();
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Regular markdown text
|
|
56
|
+
if (line) this.emit('markdown_chunk', line + '\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_tryFlushJson() {
|
|
60
|
+
try {
|
|
61
|
+
const toolCall = JSON.parse(this.jsonBuffer);
|
|
62
|
+
// Accept ANY object with a "tool" key — no hardcoded list
|
|
63
|
+
if (toolCall.tool) {
|
|
64
|
+
this.emit('tool_call', toolCall);
|
|
65
|
+
} else {
|
|
66
|
+
this.emit('markdown_chunk', this.jsonBuffer + '\n');
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
this.emit('markdown_chunk', this.jsonBuffer + '\n');
|
|
70
|
+
} finally {
|
|
71
|
+
this.inJsonBlock = false;
|
|
72
|
+
this.jsonBuffer = '';
|
|
73
|
+
this.braceDepth = 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
flush() {
|
|
78
|
+
if (this.inJsonBlock && this.jsonBuffer) {
|
|
79
|
+
this.emit('markdown_chunk', this.jsonBuffer);
|
|
80
|
+
this.jsonBuffer = '';
|
|
81
|
+
this.inJsonBlock = false;
|
|
82
|
+
this.braceDepth = 0;
|
|
83
|
+
}
|
|
84
|
+
if (this.buffer) {
|
|
85
|
+
this.emit('markdown_chunk', this.buffer);
|
|
86
|
+
this.buffer = '';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
reset() {
|
|
91
|
+
this.buffer = '';
|
|
92
|
+
this.jsonBuffer = '';
|
|
93
|
+
this.inJsonBlock = false;
|
|
94
|
+
this.braceDepth = 0;
|
|
95
|
+
}
|
|
96
|
+
}
|