@emblemvault/agentwallet 1.3.1 → 3.0.1
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 +157 -44
- package/docs/COMMANDS.md +171 -0
- package/docs/PLUGINS.md +106 -0
- package/docs/SETUP.md +180 -0
- package/emblemai.js +565 -859
- package/package.json +13 -5
- package/src/auth-server.js +300 -0
- package/src/auth.js +816 -0
- package/src/commands.js +754 -0
- package/src/formatter.js +250 -0
- package/src/glow.js +364 -0
- package/src/plugins/loader.js +533 -0
- package/src/session-store.js +94 -0
- package/src/tui.js +171 -0
package/src/formatter.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Formatting Utilities for Emblem Enhanced TUI
|
|
3
|
+
* Chalk v5 ESM — rich output formatting, tables, progress indicators
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Color Scheme
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
const colors = {
|
|
13
|
+
success: chalk.green,
|
|
14
|
+
error: chalk.red,
|
|
15
|
+
warning: chalk.yellow,
|
|
16
|
+
info: chalk.cyan,
|
|
17
|
+
dim: chalk.dim,
|
|
18
|
+
highlight: chalk.bold.white,
|
|
19
|
+
brand: chalk.bold.cyan,
|
|
20
|
+
header: chalk.bold.white,
|
|
21
|
+
border: chalk.dim,
|
|
22
|
+
profit: chalk.green,
|
|
23
|
+
loss: chalk.red,
|
|
24
|
+
neutral: chalk.gray,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// ANSI Helpers
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
function stripAnsi(str) {
|
|
32
|
+
return String(str).replace(/\x1b\[[0-9;]*m/g, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function padRight(str, width) {
|
|
36
|
+
const visible = stripAnsi(str).length;
|
|
37
|
+
const pad = Math.max(0, width - visible);
|
|
38
|
+
return str + ' '.repeat(pad);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Box and Banner Formatting
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
function box(title, content, width = 60) {
|
|
46
|
+
const lines = String(content).split('\n');
|
|
47
|
+
const inner = width - 2; // inside the left/right borders
|
|
48
|
+
|
|
49
|
+
let top;
|
|
50
|
+
if (title) {
|
|
51
|
+
const titleStr = ` ${title} `;
|
|
52
|
+
const remaining = Math.max(0, inner - titleStr.length - 1);
|
|
53
|
+
top = colors.border('┌─') + colors.brand(titleStr) + colors.border('─'.repeat(remaining)) + colors.border('┐');
|
|
54
|
+
} else {
|
|
55
|
+
top = colors.border('┌' + '─'.repeat(inner) + '┐');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const bottom = colors.border('└' + '─'.repeat(inner) + '┘');
|
|
59
|
+
|
|
60
|
+
const body = lines.map(line => {
|
|
61
|
+
const stripped = stripAnsi(line);
|
|
62
|
+
const pad = Math.max(0, inner - 2 - stripped.length);
|
|
63
|
+
return colors.border('│') + ' ' + line + ' '.repeat(pad) + ' ' + colors.border('│');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return [top, ...body, bottom].join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sectionHeader(title, width = 50) {
|
|
70
|
+
const remaining = Math.max(0, width - title.length - 5);
|
|
71
|
+
return `\n${colors.brand('━━━')} ${colors.header(title)} ${colors.brand('━'.repeat(remaining))}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function banner(lines) {
|
|
75
|
+
const maxLen = Math.max(...lines.map(l => stripAnsi(l).length));
|
|
76
|
+
const inner = maxLen + 4;
|
|
77
|
+
|
|
78
|
+
const top = colors.brand('╔' + '═'.repeat(inner) + '╗');
|
|
79
|
+
const bottom = colors.brand('╚' + '═'.repeat(inner) + '╝');
|
|
80
|
+
|
|
81
|
+
const body = lines.map(line => {
|
|
82
|
+
const pad = Math.max(0, inner - 2 - stripAnsi(line).length);
|
|
83
|
+
return colors.brand('║') + ' ' + line + ' '.repeat(pad) + ' ' + colors.brand('║');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return [top, ...body, bottom].join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Table Formatting
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
function formatTable(headers, rows, opts = {}) {
|
|
94
|
+
if (!rows || rows.length === 0) {
|
|
95
|
+
return colors.dim(' No data available');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const colCount = headers.length;
|
|
99
|
+
const aligns = opts.aligns || headers.map(() => 'left');
|
|
100
|
+
|
|
101
|
+
// Calculate column widths (ANSI-aware)
|
|
102
|
+
const widths = headers.map((h, i) => {
|
|
103
|
+
let max = stripAnsi(h).length;
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const cell = String(row[i] ?? '');
|
|
106
|
+
max = Math.max(max, stripAnsi(cell).length);
|
|
107
|
+
}
|
|
108
|
+
return max;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Apply explicit widths if provided
|
|
112
|
+
if (opts.widths) {
|
|
113
|
+
for (let i = 0; i < colCount; i++) {
|
|
114
|
+
if (opts.widths[i]) widths[i] = Math.max(widths[i], opts.widths[i]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function alignCell(str, width, align) {
|
|
119
|
+
const visible = stripAnsi(str).length;
|
|
120
|
+
const pad = Math.max(0, width - visible);
|
|
121
|
+
if (align === 'right') return ' '.repeat(pad) + str;
|
|
122
|
+
if (align === 'center') {
|
|
123
|
+
const left = Math.floor(pad / 2);
|
|
124
|
+
return ' '.repeat(left) + str + ' '.repeat(pad - left);
|
|
125
|
+
}
|
|
126
|
+
return str + ' '.repeat(pad);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const headerRow = headers
|
|
130
|
+
.map((h, i) => colors.header(alignCell(h, widths[i], aligns[i])))
|
|
131
|
+
.join(' ');
|
|
132
|
+
|
|
133
|
+
const separator = widths
|
|
134
|
+
.map(w => colors.border('─'.repeat(w)))
|
|
135
|
+
.join('──');
|
|
136
|
+
|
|
137
|
+
const dataRows = rows.map(row =>
|
|
138
|
+
row
|
|
139
|
+
.map((cell, i) => alignCell(String(cell ?? ''), widths[i], aligns[i]))
|
|
140
|
+
.join(' ')
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return ['', ` ${headerRow}`, ` ${separator}`, ...dataRows.map(r => ` ${r}`), ''].join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================================================
|
|
147
|
+
// Tool Call Formatting
|
|
148
|
+
// ============================================================================
|
|
149
|
+
|
|
150
|
+
function formatToolCall(name, args, debug = false) {
|
|
151
|
+
let line = ` ${chalk.yellow('[tool]')} ${chalk.yellow(name)}`;
|
|
152
|
+
|
|
153
|
+
if (debug && args && typeof args === 'object') {
|
|
154
|
+
const pairs = [];
|
|
155
|
+
for (const [k, v] of Object.entries(args)) {
|
|
156
|
+
if (v === undefined || v === null) continue;
|
|
157
|
+
let display;
|
|
158
|
+
if (typeof v === 'string') {
|
|
159
|
+
display = v.length > 30 ? v.slice(0, 27) + '...' : v;
|
|
160
|
+
display = display.replace(/\n/g, ' ');
|
|
161
|
+
} else if (Array.isArray(v)) {
|
|
162
|
+
display = `[${v.length} items]`;
|
|
163
|
+
} else if (typeof v === 'object') {
|
|
164
|
+
display = '{...}';
|
|
165
|
+
} else {
|
|
166
|
+
display = String(v);
|
|
167
|
+
}
|
|
168
|
+
pairs.push(`${k}=${display}`);
|
|
169
|
+
}
|
|
170
|
+
if (pairs.length) {
|
|
171
|
+
line += ` ${colors.dim('·')} ${chalk.gray(pairs.join(', '))}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return line;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// Specialized Formatters
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
function formatBalancesTable(balances) {
|
|
183
|
+
if (!balances || balances.length === 0) {
|
|
184
|
+
return colors.dim(' No balances found');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const headers = ['Chain', 'Token', 'Balance', 'USD Value'];
|
|
188
|
+
const rows = balances.map(b => [
|
|
189
|
+
String(b.chain ?? ''),
|
|
190
|
+
String(b.symbol ?? b.token ?? ''),
|
|
191
|
+
String(b.balance ?? '0'),
|
|
192
|
+
colors.highlight(String(b.usdValue ?? b.usd ?? '$0.00')),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
return formatTable(headers, rows, {
|
|
196
|
+
widths: [12, 10, 16, 14],
|
|
197
|
+
aligns: ['left', 'left', 'right', 'right'],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// Progress Indicators
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
206
|
+
|
|
207
|
+
function getSpinnerFrame(index) {
|
|
208
|
+
const i = (index >>> 0) % spinnerFrames.length;
|
|
209
|
+
return colors.brand(spinnerFrames[i]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function progressBar(percent, width = 30) {
|
|
213
|
+
const clamped = Math.max(0, Math.min(1, percent));
|
|
214
|
+
const filled = Math.round(width * clamped);
|
|
215
|
+
const empty = width - filled;
|
|
216
|
+
const bar = colors.brand('█'.repeat(filled)) + colors.dim('░'.repeat(empty));
|
|
217
|
+
const pct = colors.highlight(`${Math.round(clamped * 100)}%`);
|
|
218
|
+
return `[${bar}] ${pct}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function thinking() {
|
|
222
|
+
return `\n${colors.dim('─── ◈ Thinking... ───')}\n`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function complete(cost) {
|
|
226
|
+
const costStr = cost !== undefined ? ` $${Number(cost).toFixed(4)} ───` : ' ───';
|
|
227
|
+
return `\n${colors.dim(`─── ◈ Complete ◈ ──${costStr}`)}\n`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Export
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
const fmt = {
|
|
235
|
+
colors,
|
|
236
|
+
stripAnsi,
|
|
237
|
+
padRight,
|
|
238
|
+
box,
|
|
239
|
+
sectionHeader,
|
|
240
|
+
banner,
|
|
241
|
+
formatTable,
|
|
242
|
+
formatToolCall,
|
|
243
|
+
formatBalancesTable,
|
|
244
|
+
getSpinnerFrame,
|
|
245
|
+
progressBar,
|
|
246
|
+
thinking,
|
|
247
|
+
complete,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default fmt;
|
package/src/glow.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* glow.js - Optional glow markdown rendering integration
|
|
3
|
+
*
|
|
4
|
+
* Integrates with charmbracelet/glow for rich terminal markdown rendering.
|
|
5
|
+
* Falls back to raw markdown when glow is not installed.
|
|
6
|
+
*
|
|
7
|
+
* Note: Uses spawnSync (not exec) intentionally -- spawnSync does not invoke
|
|
8
|
+
* a shell and passes arguments as an array, so it is safe from injection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
|
|
14
|
+
let _glowInfo = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render a single header line with chalk styling.
|
|
18
|
+
* @param {number} level - 1–4
|
|
19
|
+
* @param {string} content - Header text (no # prefix)
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function formatHeader(level, content) {
|
|
23
|
+
const visLen = Math.max(content.length, 10);
|
|
24
|
+
switch (level) {
|
|
25
|
+
case 1: // H1 — bold bright cyan, uppercase, heavy underline
|
|
26
|
+
return (
|
|
27
|
+
'\n ' +
|
|
28
|
+
chalk.bold.cyanBright(content.toUpperCase()) +
|
|
29
|
+
'\n ' +
|
|
30
|
+
chalk.cyan('━'.repeat(visLen + 2)) +
|
|
31
|
+
'\n'
|
|
32
|
+
);
|
|
33
|
+
case 2: // H2 — bold white, thin underline
|
|
34
|
+
return (
|
|
35
|
+
'\n ' +
|
|
36
|
+
chalk.bold.whiteBright(content) +
|
|
37
|
+
'\n ' +
|
|
38
|
+
chalk.dim('─'.repeat(visLen)) +
|
|
39
|
+
'\n'
|
|
40
|
+
);
|
|
41
|
+
case 3: // H3 — bold white
|
|
42
|
+
return '\n ' + chalk.bold.white(content) + '\n';
|
|
43
|
+
case 4: // H4 — bold dim
|
|
44
|
+
return ' ' + chalk.bold(chalk.dim(content)) + '\n';
|
|
45
|
+
default:
|
|
46
|
+
return content;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pre-process markdown: extract header lines (#–####), style them with chalk,
|
|
52
|
+
* and return { segments, hasHeaders } for the renderer.
|
|
53
|
+
*
|
|
54
|
+
* Headers are handled by us (chalk) instead of glow because glow injects
|
|
55
|
+
* invisible characters around headers that make post-processing unreliable.
|
|
56
|
+
*
|
|
57
|
+
* Respects code blocks — lines inside ``` fences are never treated as headers.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} markdown
|
|
60
|
+
* @returns {{ segments: Array<{ type: 'header'|'content', value: string }>, hasHeaders: boolean }}
|
|
61
|
+
*/
|
|
62
|
+
function extractHeaders(markdown) {
|
|
63
|
+
const lines = markdown.split('\n');
|
|
64
|
+
const segments = [];
|
|
65
|
+
let contentBuf = [];
|
|
66
|
+
let inCode = false;
|
|
67
|
+
let hasHeaders = false;
|
|
68
|
+
|
|
69
|
+
const flushContent = () => {
|
|
70
|
+
if (contentBuf.length === 0) return;
|
|
71
|
+
segments.push({ type: 'content', value: contentBuf.join('\n') });
|
|
72
|
+
contentBuf = [];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
// Track code fences
|
|
77
|
+
if (/^(`{3,}|~{3,})/.test(line.trim())) {
|
|
78
|
+
inCode = !inCode;
|
|
79
|
+
contentBuf.push(line);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!inCode) {
|
|
84
|
+
const m = line.match(/^(#{1,4})\s+(.+)$/);
|
|
85
|
+
if (m) {
|
|
86
|
+
flushContent();
|
|
87
|
+
const clean = m[2].replace(/\s+$/, '');
|
|
88
|
+
segments.push({ type: 'header', value: formatHeader(m[1].length, clean) });
|
|
89
|
+
hasHeaders = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
contentBuf.push(line);
|
|
95
|
+
}
|
|
96
|
+
flushContent();
|
|
97
|
+
|
|
98
|
+
return { segments, hasHeaders };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Style markdown headers in plain text (no-glow fallback).
|
|
103
|
+
* Exported for direct use when glow is not involved.
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function styleHeaders(text) {
|
|
108
|
+
const { segments } = extractHeaders(text);
|
|
109
|
+
return segments.map(s => s.value).join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect whether glow is installed on the system.
|
|
114
|
+
* Result is cached after first call.
|
|
115
|
+
* @returns {{ installed: boolean, version?: string, path?: string }}
|
|
116
|
+
*/
|
|
117
|
+
export function detectGlow() {
|
|
118
|
+
if (_glowInfo !== null) return _glowInfo;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const which = spawnSync('which', ['glow'], {
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
timeout: 3000,
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (which.status !== 0 || !which.stdout.trim()) {
|
|
128
|
+
_glowInfo = { installed: false };
|
|
129
|
+
return _glowInfo;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const glowPath = which.stdout.trim();
|
|
133
|
+
|
|
134
|
+
let version;
|
|
135
|
+
try {
|
|
136
|
+
const ver = spawnSync(glowPath, ['--version'], {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
timeout: 3000,
|
|
139
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
140
|
+
});
|
|
141
|
+
if (ver.status === 0 && ver.stdout) {
|
|
142
|
+
version = ver.stdout.trim();
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// version detection is best-effort
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_glowInfo = { installed: true, version, path: glowPath };
|
|
149
|
+
} catch {
|
|
150
|
+
_glowInfo = { installed: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return _glowInfo;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pipe text through glow and return the result, or null on failure.
|
|
158
|
+
* @param {string} text
|
|
159
|
+
* @param {{ installed: boolean, path?: string }} info
|
|
160
|
+
* @param {{ style?: string, width?: number }} opts
|
|
161
|
+
* @returns {string|null}
|
|
162
|
+
* @private
|
|
163
|
+
*/
|
|
164
|
+
function _glowRender(text, info, opts) {
|
|
165
|
+
if (!info.installed || !text.trim()) return null;
|
|
166
|
+
|
|
167
|
+
const args = ['-'];
|
|
168
|
+
if (opts.style) args.push('--style', opts.style);
|
|
169
|
+
if (opts.width) args.push('--width', String(opts.width));
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const result = spawnSync(info.path, args, {
|
|
173
|
+
input: text,
|
|
174
|
+
encoding: 'utf8',
|
|
175
|
+
timeout: 5000,
|
|
176
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
177
|
+
});
|
|
178
|
+
if (result.status === 0 && result.stdout) {
|
|
179
|
+
return result.stdout;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// fall through
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Render markdown synchronously.
|
|
189
|
+
*
|
|
190
|
+
* Headers (#–####) are always styled by chalk (glow mangles them).
|
|
191
|
+
* Everything else goes through glow if installed, raw text otherwise.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} markdown - Markdown text to render
|
|
194
|
+
* @param {{ style?: 'dark'|'light'|'notty'|'auto', width?: number }} opts
|
|
195
|
+
* @returns {string} Rendered output
|
|
196
|
+
*/
|
|
197
|
+
export function renderMarkdownSync(markdown, opts = {}) {
|
|
198
|
+
const { segments, hasHeaders } = extractHeaders(markdown);
|
|
199
|
+
const info = detectGlow();
|
|
200
|
+
|
|
201
|
+
// Fast path: no headers → send entire markdown to glow in one call
|
|
202
|
+
if (!hasHeaders) {
|
|
203
|
+
return _glowRender(markdown, info, opts) || markdown;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Headers found: render each segment appropriately
|
|
207
|
+
const rendered = segments.map(seg => {
|
|
208
|
+
if (seg.type === 'header') return seg.value;
|
|
209
|
+
// Content segment → glow
|
|
210
|
+
return _glowRender(seg.value, info, opts) || seg.value;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return rendered.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Streaming buffer that accumulates markdown text and flushes
|
|
218
|
+
* at safe paragraph/block boundaries through glow.
|
|
219
|
+
*
|
|
220
|
+
* Ported from hustle-v5 src/cli/glow.ts — matches its breakpoint
|
|
221
|
+
* detection so streaming + glow renders incrementally.
|
|
222
|
+
*/
|
|
223
|
+
export class GlowStreamBuffer {
|
|
224
|
+
constructor() {
|
|
225
|
+
this.buffer = '';
|
|
226
|
+
this.inCodeBlock = false;
|
|
227
|
+
this.codeBlockFence = '';
|
|
228
|
+
this.lastRenderedLength = 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Push text into the buffer.
|
|
233
|
+
* Returns rendered string if a safe flush point was reached, null otherwise.
|
|
234
|
+
* @param {string} text
|
|
235
|
+
* @returns {string|null}
|
|
236
|
+
*/
|
|
237
|
+
push(text) {
|
|
238
|
+
this.buffer += text;
|
|
239
|
+
return this._tryFlush();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Force-render any remaining buffer content through glow.
|
|
244
|
+
* @returns {string|null}
|
|
245
|
+
*/
|
|
246
|
+
flush() {
|
|
247
|
+
if (this.buffer.length === 0) return null;
|
|
248
|
+
const content = this.buffer;
|
|
249
|
+
this.buffer = '';
|
|
250
|
+
this.inCodeBlock = false;
|
|
251
|
+
this.codeBlockFence = '';
|
|
252
|
+
this.lastRenderedLength = 0;
|
|
253
|
+
|
|
254
|
+
const info = detectGlow();
|
|
255
|
+
if (!info.installed) return content;
|
|
256
|
+
return renderMarkdownSync(content);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Clear the buffer without rendering.
|
|
261
|
+
*/
|
|
262
|
+
reset() {
|
|
263
|
+
this.buffer = '';
|
|
264
|
+
this.inCodeBlock = false;
|
|
265
|
+
this.codeBlockFence = '';
|
|
266
|
+
this.lastRenderedLength = 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Check for markdown-safe breakpoints and flush if found.
|
|
271
|
+
* @returns {string|null}
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
_tryFlush() {
|
|
275
|
+
this._updateCodeBlockState();
|
|
276
|
+
|
|
277
|
+
// Don't flush mid-code-block
|
|
278
|
+
if (this.inCodeBlock) return null;
|
|
279
|
+
|
|
280
|
+
const breakpoint = this._findBreakpoint();
|
|
281
|
+
if (breakpoint === -1) return null;
|
|
282
|
+
|
|
283
|
+
const content = this.buffer.slice(0, breakpoint);
|
|
284
|
+
this.buffer = this.buffer.slice(breakpoint);
|
|
285
|
+
this.lastRenderedLength = this.buffer.length;
|
|
286
|
+
|
|
287
|
+
if (content.length === 0) return null;
|
|
288
|
+
|
|
289
|
+
const info = detectGlow();
|
|
290
|
+
if (!info.installed) return content;
|
|
291
|
+
return renderMarkdownSync(content);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Update code block tracking state — only scans new content,
|
|
296
|
+
* and matches opening/closing fences by style.
|
|
297
|
+
* @private
|
|
298
|
+
*/
|
|
299
|
+
_updateCodeBlockState() {
|
|
300
|
+
const searchStart = this.lastRenderedLength;
|
|
301
|
+
const newContent = this.buffer.slice(searchStart);
|
|
302
|
+
const fenceRegex = /^(```|~~~)/gm;
|
|
303
|
+
const matches = [...newContent.matchAll(fenceRegex)];
|
|
304
|
+
|
|
305
|
+
for (const match of matches) {
|
|
306
|
+
const fence = match[1];
|
|
307
|
+
if (!this.inCodeBlock) {
|
|
308
|
+
this.inCodeBlock = true;
|
|
309
|
+
this.codeBlockFence = fence;
|
|
310
|
+
} else if (fence === this.codeBlockFence) {
|
|
311
|
+
this.inCodeBlock = false;
|
|
312
|
+
this.codeBlockFence = '';
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.lastRenderedLength = this.buffer.length;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Find a safe breakpoint in the buffer.
|
|
321
|
+
* Returns the index after the breakpoint, or -1 if none found.
|
|
322
|
+
* @returns {number}
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_findBreakpoint() {
|
|
326
|
+
// Priority 1: Paragraph break (double newline)
|
|
327
|
+
const paragraphBreak = this.buffer.indexOf('\n\n');
|
|
328
|
+
if (paragraphBreak !== -1) {
|
|
329
|
+
return paragraphBreak + 2;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Priority 2: End of a complete line that looks like a block element
|
|
333
|
+
// (header, HR) followed by newline at buffer end
|
|
334
|
+
if (this.buffer.endsWith('\n') && this.buffer.length > 1) {
|
|
335
|
+
const lines = this.buffer.split('\n');
|
|
336
|
+
// Need at least 2 elements (last is empty due to trailing \n)
|
|
337
|
+
if (lines.length >= 2) {
|
|
338
|
+
const lastLine = lines[lines.length - 2];
|
|
339
|
+
if (this._isBlockElement(lastLine)) {
|
|
340
|
+
return this.buffer.length;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return -1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Check if a line is a standalone block element that's safe to render.
|
|
350
|
+
* @param {string} line
|
|
351
|
+
* @returns {boolean}
|
|
352
|
+
* @private
|
|
353
|
+
*/
|
|
354
|
+
_isBlockElement(line) {
|
|
355
|
+
const trimmed = line.trim();
|
|
356
|
+
// Headers
|
|
357
|
+
if (/^#{1,6}\s/.test(trimmed)) return true;
|
|
358
|
+
// Horizontal rules
|
|
359
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) return true;
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export default { detectGlow, renderMarkdownSync, styleHeaders, GlowStreamBuffer };
|