@pellux/goodvibes-agent 0.1.59 → 0.1.60

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.
@@ -1,542 +0,0 @@
1
- /**
2
- * SyntaxHighlighter — Tree-sitter-powered syntax highlighting for code blocks.
3
- *
4
- * Designed for the synchronous TUI render loop:
5
- * - Initializes tree-sitter WASM and grammar parsers asynchronously in background
6
- * - Returns cached highlight data synchronously from highlight()
7
- * - Falls back to empty array (caller uses regex tokenizer) when parser not ready
8
- * - Caches parsed results keyed by language + content hash to avoid re-parsing
9
- *
10
- * Vaporwave color theme:
11
- * Keywords: #d000ff (purple)
12
- * Strings: #00ff88 (green)
13
- * Numbers: #ffcc00 (yellow)
14
- * Comments: #666666 (dim grey)
15
- * Functions/methods: #00ffff (cyan)
16
- * Types/classes: #ff6b9d (pink)
17
- * Operators: #ffffff (white)
18
- * Properties: #87ceeb (light blue)
19
- * Built-ins/special: #ff8c00 (orange)
20
- * Default: 252 (light grey)
21
- */
22
- import type { Node } from 'web-tree-sitter';
23
- import { TreeSitterService } from '@pellux/goodvibes-sdk/platform/intelligence';
24
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
25
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
26
-
27
- // ─── Types ───────────────────────────────────────────────────────────────────
28
-
29
- export interface SyntaxToken {
30
- text: string;
31
- fg: string;
32
- bold?: boolean;
33
- italic?: boolean;
34
- }
35
-
36
- export type HighlightedLine = SyntaxToken[];
37
-
38
- // ─── Language Alias Map ──────────────────────────────────────────────────────
39
-
40
- // Maps fence tag language strings → tree-sitter language IDs
41
- const FENCE_TO_LANG_ID: Record<string, string> = {
42
- ts: 'typescript',
43
- tsx: 'tsx',
44
- typescript: 'typescript',
45
- js: 'javascript',
46
- jsx: 'javascript',
47
- javascript: 'javascript',
48
- mjs: 'javascript',
49
- py: 'python',
50
- python: 'python',
51
- rs: 'rust',
52
- rust: 'rust',
53
- go: 'go',
54
- golang: 'go',
55
- json: 'json',
56
- json5: 'json',
57
- css: 'css',
58
- scss: 'css',
59
- sh: 'bash',
60
- bash: 'bash',
61
- shell: 'bash',
62
- zsh: 'bash',
63
- };
64
-
65
- // ─── Vaporwave Color Mapping ─────────────────────────────────────────────────
66
-
67
- // Map tree-sitter node types to vaporwave theme colors.
68
- // The node types are specific to each grammar's output.
69
- const NODE_TYPE_COLORS: Record<string, { fg: string; bold?: boolean; italic?: boolean }> = {
70
- // ── Keywords
71
- 'if': { fg: '#d000ff', bold: true },
72
- 'else': { fg: '#d000ff', bold: true },
73
- 'return': { fg: '#d000ff', bold: true },
74
- 'const': { fg: '#d000ff', bold: true },
75
- 'let': { fg: '#d000ff', bold: true },
76
- 'var': { fg: '#d000ff', bold: true },
77
- 'function': { fg: '#d000ff', bold: true },
78
- 'class': { fg: '#d000ff', bold: true },
79
- 'import': { fg: '#d000ff', bold: true },
80
- 'export': { fg: '#d000ff', bold: true },
81
- 'from': { fg: '#d000ff', bold: true },
82
- 'new': { fg: '#d000ff', bold: true },
83
- 'typeof': { fg: '#d000ff', bold: true },
84
- 'instanceof': { fg: '#d000ff', bold: true },
85
- 'in': { fg: '#d000ff', bold: true },
86
- 'of': { fg: '#d000ff', bold: true },
87
- 'for': { fg: '#d000ff', bold: true },
88
- 'while': { fg: '#d000ff', bold: true },
89
- 'do': { fg: '#d000ff', bold: true },
90
- 'switch': { fg: '#d000ff', bold: true },
91
- 'case': { fg: '#d000ff', bold: true },
92
- 'break': { fg: '#d000ff', bold: true },
93
- 'continue': { fg: '#d000ff', bold: true },
94
- 'throw': { fg: '#d000ff', bold: true },
95
- 'try': { fg: '#d000ff', bold: true },
96
- 'catch': { fg: '#d000ff', bold: true },
97
- 'finally': { fg: '#d000ff', bold: true },
98
- 'async': { fg: '#d000ff', bold: true },
99
- 'await': { fg: '#d000ff', bold: true },
100
- 'yield': { fg: '#d000ff', bold: true },
101
- 'delete': { fg: '#d000ff', bold: true },
102
- 'void': { fg: '#d000ff', bold: true },
103
- 'static': { fg: '#d000ff', bold: true },
104
- 'extends': { fg: '#d000ff', bold: true },
105
- 'implements': { fg: '#d000ff', bold: true },
106
- 'interface': { fg: '#d000ff', bold: true },
107
- 'type': { fg: '#d000ff', bold: true },
108
- 'enum': { fg: '#d000ff', bold: true },
109
- 'namespace': { fg: '#d000ff', bold: true },
110
- 'abstract': { fg: '#d000ff', bold: true },
111
- 'readonly': { fg: '#d000ff', bold: true },
112
- 'as': { fg: '#d000ff', bold: true },
113
- 'satisfies': { fg: '#d000ff', bold: true },
114
- // Python keywords
115
- 'def': { fg: '#d000ff', bold: true },
116
- 'lambda': { fg: '#d000ff', bold: true },
117
- 'with': { fg: '#d000ff', bold: true },
118
- 'pass': { fg: '#d000ff', bold: true },
119
- 'global': { fg: '#d000ff', bold: true },
120
- 'nonlocal': { fg: '#d000ff', bold: true },
121
- 'assert': { fg: '#d000ff', bold: true },
122
- 'raise': { fg: '#d000ff', bold: true },
123
- 'except': { fg: '#d000ff', bold: true },
124
- 'elif': { fg: '#d000ff', bold: true },
125
- 'and': { fg: '#d000ff', bold: true },
126
- 'or': { fg: '#d000ff', bold: true },
127
- 'not': { fg: '#d000ff', bold: true },
128
- 'is': { fg: '#d000ff', bold: true },
129
- // Bash keywords
130
- 'then': { fg: '#d000ff', bold: true },
131
- 'fi': { fg: '#d000ff', bold: true },
132
- 'done': { fg: '#d000ff', bold: true },
133
- 'esac': { fg: '#d000ff', bold: true },
134
-
135
- // ── Strings
136
- 'string': { fg: '#00ff88' },
137
- 'string_fragment': { fg: '#00ff88' },
138
- 'template_string': { fg: '#00ff88' },
139
- 'escape_sequence': { fg: '#00ff88' },
140
- 'raw_string': { fg: '#00ff88' },
141
- 'concatenated_string': { fg: '#00ff88' },
142
- 'string_content': { fg: '#00ff88' },
143
- 'quoted_attribute_value': { fg: '#00ff88' },
144
- 'attribute_value': { fg: '#00ff88' },
145
- 'pair_value': { fg: '#00ff88' },
146
- 'plain_value': { fg: '#00ff88' },
147
-
148
- // ── Numbers
149
- 'number': { fg: '#ffcc00' },
150
- 'integer': { fg: '#ffcc00' },
151
- 'float': { fg: '#ffcc00' },
152
- 'decimal_integer_literal': { fg: '#ffcc00' },
153
- 'hex_integer_literal': { fg: '#ffcc00' },
154
- 'octal_integer_literal': { fg: '#ffcc00' },
155
- 'binary_integer_literal': { fg: '#ffcc00' },
156
-
157
- // ── Comments
158
- 'comment': { fg: '#666666', italic: true },
159
- 'line_comment': { fg: '#666666', italic: true },
160
- 'block_comment': { fg: '#666666', italic: true },
161
- 'shebang': { fg: '#666666', italic: true },
162
-
163
- // ── Functions/methods
164
- 'function_declaration': { fg: '#00ffff' },
165
- 'method_declaration': { fg: '#00ffff' },
166
- 'method_definition': { fg: '#00ffff' },
167
- 'arrow_function': { fg: '#00ffff' },
168
- 'function_expression': { fg: '#00ffff' },
169
- 'call_expression': { fg: '#00ffff' },
170
- 'function_definition': { fg: '#00ffff' }, // Python
171
-
172
- // ── Types and classes
173
- 'type_identifier': { fg: '#ff6b9d' },
174
- 'type_annotation': { fg: '#ff6b9d' },
175
- 'class_declaration': { fg: '#ff6b9d' },
176
- 'class_definition': { fg: '#ff6b9d' }, // Python
177
- 'interface_declaration': { fg: '#ff6b9d' },
178
- 'type_alias_declaration': { fg: '#ff6b9d' },
179
- 'predefined_type': { fg: '#ff6b9d' },
180
- 'builtin_type': { fg: '#ff6b9d' },
181
- 'tag_name': { fg: '#ff6b9d' },
182
- 'element': { fg: '#ff6b9d' },
183
-
184
- // ── Operators
185
- '+': { fg: '#ffffff' },
186
- '-': { fg: '#ffffff' },
187
- '*': { fg: '#ffffff' },
188
- '/': { fg: '#ffffff' },
189
- '%': { fg: '#ffffff' },
190
- '=': { fg: '#ffffff' },
191
- '==': { fg: '#ffffff' },
192
- '===': { fg: '#ffffff' },
193
- '!=': { fg: '#ffffff' },
194
- '!==': { fg: '#ffffff' },
195
- '<': { fg: '#ffffff' },
196
- '>': { fg: '#ffffff' },
197
- '<=': { fg: '#ffffff' },
198
- '>=': { fg: '#ffffff' },
199
- '&&': { fg: '#ffffff' },
200
- '||': { fg: '#ffffff' },
201
- '??': { fg: '#ffffff' },
202
- '=>': { fg: '#ffffff' },
203
- '!': { fg: '#ffffff' },
204
- '&': { fg: '#ffffff' },
205
- '|': { fg: '#ffffff' },
206
- '^': { fg: '#ffffff' },
207
- '~': { fg: '#ffffff' },
208
- '<<': { fg: '#ffffff' },
209
- '>>': { fg: '#ffffff' },
210
- '>>>': { fg: '#ffffff' },
211
-
212
- // ── Properties
213
- 'property_identifier': { fg: '#87ceeb' },
214
- 'shorthand_property_identifier': { fg: '#87ceeb' },
215
- 'attribute_name': { fg: '#87ceeb' },
216
- 'property_name': { fg: '#87ceeb' },
217
- 'pair_key': { fg: '#87ceeb' },
218
-
219
- // ── Built-ins / special values
220
- 'true': { fg: '#ff8c00' },
221
- 'false': { fg: '#ff8c00' },
222
- 'null': { fg: '#ff8c00' },
223
- 'undefined': { fg: '#ff8c00' },
224
- 'none': { fg: '#ff8c00' },
225
- 'None': { fg: '#ff8c00' },
226
- 'True': { fg: '#ff8c00' },
227
- 'False': { fg: '#ff8c00' },
228
- 'this': { fg: '#ff8c00' },
229
- 'super': { fg: '#ff8c00' },
230
- 'self': { fg: '#ff8c00' },
231
- 'boolean': { fg: '#ff8c00' },
232
-
233
- // ── JSON specific
234
- 'json_string': { fg: '#00ff88' },
235
- 'json_key': { fg: '#87ceeb' },
236
- 'json_number': { fg: '#ffcc00' },
237
-
238
- // ── CSS specific
239
- 'class_selector': { fg: '#ff6b9d' },
240
- 'id_selector': { fg: '#ff6b9d' },
241
- 'pseudo_class_selector': { fg: '#d000ff' },
242
- 'pseudo_element_selector': { fg: '#d000ff' },
243
- 'property_name_css': { fg: '#87ceeb' },
244
- 'unit': { fg: '#ffcc00' },
245
- 'color_value': { fg: '#00ff88' },
246
- 'at_keyword': { fg: '#d000ff', bold: true },
247
- 'important': { fg: '#d000ff', bold: true },
248
- };
249
-
250
- // Default color for unrecognized node types
251
- const DEFAULT_FG = '252';
252
-
253
- // ─── Content Hash ─────────────────────────────────────────────────────────────
254
-
255
- /** Cheap DJB2-variant hash for cache keying. */
256
- function hashString(s: string): number {
257
- let h = 5381;
258
- for (let i = 0; i < s.length; i++) {
259
- h = ((h << 5) + h) ^ s.charCodeAt(i);
260
- h = h >>> 0; // keep unsigned 32-bit
261
- }
262
- return h;
263
- }
264
-
265
- // ─── AST Walker ──────────────────────────────────────────────────────────────
266
-
267
- interface Span {
268
- startRow: number;
269
- startCol: number;
270
- endRow: number;
271
- endCol: number;
272
- text: string;
273
- fg: string;
274
- bold?: boolean;
275
- italic?: boolean;
276
- }
277
-
278
- /**
279
- * Walk the AST and collect leaf nodes with their positions and colors.
280
- * Returns a flat array of colored spans that covers all tokens in the code.
281
- */
282
- function collectSpans(root: Node, code: string): Span[] {
283
- const spans: Span[] = [];
284
-
285
- function getStyle(node: Node): { fg: string; bold?: boolean; italic?: boolean } | null {
286
- // Named nodes (keywords, identifiers, etc.)
287
- const namedStyle = NODE_TYPE_COLORS[node.type];
288
- if (namedStyle) return namedStyle;
289
-
290
- // Anonymous nodes (punctuation, operators, keywords stored as literals)
291
- if (!node.isNamed) {
292
- const text = node.text.trim();
293
- const literalStyle = NODE_TYPE_COLORS[text];
294
- if (literalStyle) return literalStyle;
295
- }
296
-
297
- return null;
298
- }
299
-
300
- function visit(node: Node): void {
301
- // Leaf nodes: emit a span
302
- if (node.childCount === 0) {
303
- const style = getStyle(node);
304
- const text = node.text;
305
- if (text.length === 0) return;
306
- spans.push({
307
- startRow: node.startPosition.row,
308
- startCol: node.startPosition.column,
309
- endRow: node.endPosition.row,
310
- endCol: node.endPosition.column,
311
- text,
312
- fg: style?.fg ?? DEFAULT_FG,
313
- bold: style?.bold,
314
- italic: style?.italic,
315
- });
316
- return;
317
- }
318
-
319
- // For named nodes with a dominant style (comments, strings, etc.),
320
- // emit as a single span rather than recursing into children.
321
- // This prevents partial coloring of multi-char nodes.
322
- const style = getStyle(node);
323
- if (style && isLeafLike(node)) {
324
- const text = node.text;
325
- if (text.length === 0) return;
326
- spans.push({
327
- startRow: node.startPosition.row,
328
- startCol: node.startPosition.column,
329
- endRow: node.endPosition.row,
330
- endCol: node.endPosition.column,
331
- text,
332
- fg: style.fg,
333
- bold: style.bold,
334
- italic: style.italic,
335
- });
336
- return;
337
- }
338
-
339
- // Recurse into children
340
- for (let i = 0; i < node.childCount; i++) {
341
- const child = node.child(i);
342
- if (child) visit(child);
343
- }
344
- }
345
-
346
- visit(root);
347
-
348
- // Sort spans by start position for proper ordering
349
- spans.sort((a, b) => {
350
- if (a.startRow !== b.startRow) return a.startRow - b.startRow;
351
- return a.startCol - b.startCol;
352
- });
353
-
354
- return spans;
355
- }
356
-
357
- /** Node types where we emit the whole subtree as one colored span. */
358
- const LEAF_LIKE_TYPES = new Set([
359
- 'string', 'template_string', 'comment', 'line_comment', 'block_comment',
360
- 'raw_string', 'concatenated_string', 'string_content', 'shebang',
361
- 'attribute_value', 'quoted_attribute_value',
362
- ]);
363
-
364
- function isLeafLike(node: Node): boolean {
365
- return LEAF_LIKE_TYPES.has(node.type);
366
- }
367
-
368
- // ─── Span → Per-line Token Arrays ────────────────────────────────────────────
369
-
370
- /**
371
- * Convert a flat list of positioned spans into per-line SyntaxToken arrays.
372
- * Handles multi-line spans (e.g., block comments, template literals).
373
- */
374
- function spansToLines(spans: Span[], codeLines: string[]): HighlightedLine[] {
375
- // Initialize result: one entry per code line, each starting with no tokens
376
- const result: HighlightedLine[] = codeLines.map(() => []);
377
-
378
- // Track the current position to emit default-colored text for gaps
379
- const linePositions: number[] = codeLines.map(() => 0);
380
-
381
- for (const span of spans) {
382
- // For single-line spans
383
- if (span.startRow === span.endRow) {
384
- const row = span.startRow;
385
- if (row >= codeLines.length) continue;
386
-
387
- // Skip if we've already passed this position (overlap)
388
- if (linePositions[row] > span.startCol) continue;
389
-
390
- // Emit gap text with default color
391
- const currentCol = linePositions[row];
392
- if (currentCol < span.startCol) {
393
- const gapText = codeLines[row].slice(currentCol, span.startCol);
394
- if (gapText) result[row].push({ text: gapText, fg: DEFAULT_FG });
395
- }
396
-
397
- const tokenText = codeLines[row].slice(span.startCol, span.endCol);
398
- if (tokenText) {
399
- result[row].push({ text: tokenText, fg: span.fg, bold: span.bold, italic: span.italic });
400
- }
401
- linePositions[row] = span.endCol;
402
- } else {
403
- // Multi-line span: slice each line
404
- for (let r = span.startRow; r <= span.endRow; r++) {
405
- if (r >= codeLines.length) break;
406
-
407
- const colStart = r === span.startRow ? span.startCol : 0;
408
- const colEnd = r === span.endRow ? span.endCol : codeLines[r].length;
409
-
410
- // Overlap guard FIRST
411
- if (linePositions[r] > colStart) continue;
412
-
413
- // Emit gap
414
- const currentCol = linePositions[r];
415
- if (currentCol < colStart) {
416
- const gapText = codeLines[r].slice(currentCol, colStart);
417
- if (gapText) result[r].push({ text: gapText, fg: DEFAULT_FG });
418
- }
419
-
420
- const tokenText = codeLines[r].slice(colStart, colEnd);
421
- if (tokenText) {
422
- result[r].push({ text: tokenText, fg: span.fg, bold: span.bold, italic: span.italic });
423
- }
424
- linePositions[r] = colEnd;
425
- }
426
- }
427
- }
428
-
429
- // Fill remaining text on each line with default color
430
- for (let r = 0; r < codeLines.length; r++) {
431
- const remaining = codeLines[r].slice(linePositions[r]);
432
- if (remaining) result[r].push({ text: remaining, fg: DEFAULT_FG });
433
- }
434
-
435
- return result;
436
- }
437
-
438
- // ─── SyntaxHighlighter Class ──────────────────────────────────────────────────
439
-
440
- const MAX_HIGHLIGHT_CACHE = 200;
441
-
442
- export class SyntaxHighlighter {
443
- private service: TreeSitterService;
444
- private cache: Map<string, HighlightedLine[]> = new Map();
445
- private pending: Set<string> = new Set();
446
-
447
- constructor() {
448
- this.service = new TreeSitterService();
449
- // Kick off WASM initialization in background
450
- this.service.initialize().catch((err: unknown) => {
451
- logger.warn('SyntaxHighlighter: background init failed', { error: summarizeError(err) });
452
- });
453
- }
454
-
455
- /**
456
- * Map a fence tag language string to a tree-sitter language ID.
457
- * Returns null if the language is not supported by tree-sitter.
458
- */
459
- fenceToLangId(fenceTag: string): string | null {
460
- return FENCE_TO_LANG_ID[fenceTag.toLowerCase()] ?? null;
461
- }
462
-
463
- /**
464
- * Synchronous highlight lookup.
465
- *
466
- * If the highlight cache has a result for this code+language, returns it.
467
- * Otherwise, schedules an async parse in background and returns null.
468
- * Callers should fall back to regex-based tokenization when null is returned.
469
- */
470
- highlight(code: string, fenceTag: string): HighlightedLine[] | null {
471
- const langId = this.fenceToLangId(fenceTag);
472
- if (!langId) return null; // unsupported language
473
-
474
- const key = `${langId}:${hashString(code)}`;
475
- const cached = this.cache.get(key);
476
- if (cached) return cached;
477
-
478
- // Schedule background parse if not already pending
479
- if (!this.pending.has(key)) {
480
- this.scheduleParse(code, langId, key);
481
- }
482
-
483
- return null; // not ready yet
484
- }
485
-
486
- /**
487
- * Schedule an async parse. Fires and forgets — result lands in cache.
488
- * Callers will pick it up on the next render cycle.
489
- */
490
- private scheduleParse(code: string, langId: string, key: string): void {
491
- this.pending.add(key);
492
-
493
- // Use a stable virtual path for the parser cache key
494
- const virtualPath = `__highlight__.${langId}`;
495
-
496
- Promise.resolve().then(async () => {
497
- try {
498
- // Ensure the grammar is loaded
499
- const language = await this.service.loadLanguage(langId);
500
- if (!language) {
501
- logger.debug('SyntaxHighlighter: grammar not available', { langId });
502
- return;
503
- }
504
-
505
- // Parse the code
506
- const tree = await this.service.parse(virtualPath, code, langId);
507
- if (!tree) {
508
- logger.debug('SyntaxHighlighter: parse returned null', { langId });
509
- return;
510
- }
511
-
512
- // Walk AST and build per-line token arrays
513
- const codeLines = code.split('\n');
514
- const spans = collectSpans(tree.rootNode, code);
515
- const highlighted = spansToLines(spans, codeLines);
516
-
517
- // Evict oldest entry if at capacity (FIFO)
518
- if (this.cache.size >= MAX_HIGHLIGHT_CACHE) {
519
- const firstKey = this.cache.keys().next().value;
520
- if (firstKey !== undefined) this.cache.delete(firstKey);
521
- }
522
-
523
- this.cache.set(key, highlighted);
524
- logger.debug('SyntaxHighlighter: parsed and cached', { langId, lines: codeLines.length });
525
- } catch (err) {
526
- logger.warn('SyntaxHighlighter: parse error', { langId, error: summarizeError(err) });
527
- } finally {
528
- this.pending.delete(key);
529
- }
530
- });
531
- }
532
-
533
- /** Clear all cached highlights (e.g., on theme change). */
534
- clearCache(): void {
535
- this.cache.clear();
536
- }
537
-
538
- /** Current cache size (for diagnostics). */
539
- get cacheSize(): number {
540
- return this.cache.size;
541
- }
542
- }