@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.
@@ -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 };