@leftium/gg 0.0.31 → 0.0.34

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,4 +1,6 @@
1
+ import { DEV } from 'esm-env';
1
2
  import { LogBuffer } from './buffer.js';
3
+ const _ggCallSitesPlugin = typeof __GG_TAG_PLUGIN__ !== 'undefined' ? __GG_TAG_PLUGIN__ : false;
2
4
  /**
3
5
  * Creates the gg Eruda plugin
4
6
  *
@@ -17,34 +19,142 @@ export function createGgPlugin(options, gg) {
17
19
  let filterExpanded = false;
18
20
  let filterPattern = '';
19
21
  const enabledNamespaces = new Set();
22
+ // Last rendered entries (for hover tooltip arg lookup)
23
+ let renderedEntries = [];
24
+ // Settings UI state
25
+ let settingsExpanded = false;
26
+ // Filter pattern persistence key (independent of localStorage.debug)
27
+ const FILTER_KEY = 'gg-filter';
28
+ // Namespace click action: 'open' uses Vite dev middleware, 'copy' copies formatted string, 'open-url' navigates to URI
29
+ const NS_ACTION_KEY = 'gg-ns-action';
30
+ const EDITOR_BIN_KEY = 'gg-editor-bin';
31
+ const COPY_FORMAT_KEY = 'gg-copy-format';
32
+ const URL_FORMAT_KEY = 'gg-url-format';
33
+ const PROJECT_ROOT_KEY = 'gg-project-root';
34
+ // Plugin detection state (probed once at init)
35
+ let openInEditorPluginDetected = null; // null = not yet probed
36
+ // Editor bins for launch-editor (common first, then alphabetical)
37
+ const editorBins = [
38
+ { label: 'Auto-detect', value: '' },
39
+ { label: 'VS Code', value: 'code' },
40
+ { label: 'Cursor', value: 'cursor' },
41
+ { label: 'Zed', value: 'zed' },
42
+ { label: 'Sublime Text', value: 'sublime' },
43
+ { label: 'Vim', value: 'vim' },
44
+ { label: 'Emacs', value: 'emacs' },
45
+ { label: 'WebStorm', value: 'webstorm' },
46
+ { label: 'IDEA', value: 'idea' },
47
+ { label: 'Atom', value: 'atom' },
48
+ { label: 'AppCode', value: 'appcode' },
49
+ { label: 'Brackets', value: 'brackets' },
50
+ { label: 'CLion', value: 'clion' },
51
+ { label: 'Code Insiders', value: 'code-insiders' },
52
+ { label: 'Notepad++', value: 'notepad++' },
53
+ { label: 'PhpStorm', value: 'phpstorm' },
54
+ { label: 'PyCharm', value: 'pycharm' },
55
+ { label: 'Rider', value: 'rider' },
56
+ { label: 'RubyMine', value: 'rubymine' },
57
+ { label: 'VSCodium', value: 'codium' },
58
+ { label: 'Visual Studio', value: 'visualstudio' }
59
+ ];
60
+ // Terminal command presets
61
+ const copyPresets = {
62
+ 'Raw path': '$FILE:$LINE:$COL',
63
+ 'VS Code': 'code -g $FILE:$LINE:$COL',
64
+ Cursor: 'cursor -g $FILE:$LINE:$COL',
65
+ Zed: 'zed $FILE:$LINE:$COL',
66
+ Vim: 'vim +$LINE $FILE',
67
+ Emacs: 'emacs +$LINE:$COL $FILE',
68
+ JetBrains: 'idea --line $LINE --column $COL $FILE'
69
+ };
70
+ // URI scheme presets (use $ROOT for absolute paths)
71
+ const uriPresets = {
72
+ 'VS Code': 'vscode://file/$ROOT/$FILE:$LINE:$COL',
73
+ 'VS Code Insiders': 'vscode-insiders://file/$ROOT/$FILE:$LINE:$COL',
74
+ Cursor: 'cursor://file/$ROOT/$FILE:$LINE:$COL',
75
+ Windsurf: 'windsurf://file/$ROOT/$FILE:$LINE:$COL',
76
+ VSCodium: 'vscodium://file/$ROOT/$FILE:$LINE:$COL',
77
+ Zed: 'zed://file/$ROOT/$FILE:$LINE:$COL',
78
+ JetBrains: 'jetbrains://open?file=$ROOT/$FILE&line=$LINE&column=$COL',
79
+ 'Sublime Text': 'subl://open?url=file://$ROOT/$FILE&line=$LINE&column=$COL',
80
+ Emacs: 'org-protocol://open-source?url=file://$ROOT/$FILE&line=$LINE&col=$COL',
81
+ Atom: 'atom://open?url=file://$ROOT/$FILE&line=$LINE&column=$COL'
82
+ };
83
+ let nsClickAction = localStorage.getItem(NS_ACTION_KEY) || (DEV ? 'open' : 'copy');
84
+ let editorBin = localStorage.getItem(EDITOR_BIN_KEY) || '';
85
+ let copyFormat = localStorage.getItem(COPY_FORMAT_KEY) || copyPresets['Raw path'];
86
+ let urlFormat = localStorage.getItem(URL_FORMAT_KEY) || uriPresets['VS Code'];
87
+ let projectRoot = localStorage.getItem(PROJECT_ROOT_KEY) || '';
88
+ /** Get the active format string for the current action mode */
89
+ function activeFormat() {
90
+ return nsClickAction === 'open-url' ? urlFormat : copyFormat;
91
+ }
92
+ /** Set the active format string and persist it */
93
+ function setActiveFormat(value) {
94
+ if (nsClickAction === 'open-url') {
95
+ urlFormat = value;
96
+ localStorage.setItem(URL_FORMAT_KEY, urlFormat);
97
+ }
98
+ else {
99
+ copyFormat = value;
100
+ localStorage.setItem(COPY_FORMAT_KEY, copyFormat);
101
+ }
102
+ }
20
103
  const plugin = {
21
104
  name: 'GG',
22
105
  init($container) {
23
106
  $el = $container;
107
+ // Load filter state BEFORE registering _onLog hook, because setting _onLog
108
+ // triggers replay of earlyLogBuffer and each entry checks filterPattern
109
+ filterPattern = localStorage.getItem(FILTER_KEY) || 'gg:*';
24
110
  // Register the capture hook on gg
25
111
  if (gg) {
26
112
  gg._onLog = (entry) => {
113
+ // Track namespaces for filter UI update (check BEFORE pushing to buffer)
114
+ const hadNamespace = getAllCapturedNamespaces().includes(entry.namespace);
27
115
  buffer.push(entry);
28
116
  // Add new namespace to enabledNamespaces if it matches the current pattern
29
117
  const effectivePattern = filterPattern || 'gg:*';
30
118
  if (namespaceMatchesPattern(entry.namespace, effectivePattern)) {
31
119
  enabledNamespaces.add(entry.namespace);
32
120
  }
33
- // Update filter UI if expanded (new namespace may have appeared)
34
- if (filterExpanded) {
121
+ // Update filter UI if new namespace appeared (updates button summary count)
122
+ if (!hadNamespace) {
35
123
  renderFilterUI();
36
124
  }
37
125
  renderLogs();
38
126
  };
39
127
  }
40
- // Load initial filter state
41
- filterPattern = localStorage.getItem('debug') || '';
128
+ // Probe for openInEditorPlugin (status 222) and auto-populate $ROOT in dev mode
129
+ if (DEV) {
130
+ fetch('/__open-in-editor?file=+')
131
+ .then((r) => {
132
+ openInEditorPluginDetected = r.status === 222;
133
+ // If plugin detected, fetch project root for $ROOT variable
134
+ if (openInEditorPluginDetected && !projectRoot) {
135
+ return fetch('/__gg-project-root').then((r) => r.text());
136
+ }
137
+ })
138
+ .then((root) => {
139
+ if (root) {
140
+ projectRoot = root.trim();
141
+ localStorage.setItem(PROJECT_ROOT_KEY, projectRoot);
142
+ }
143
+ // Re-render settings if panel is open (to show detection result)
144
+ if (settingsExpanded)
145
+ renderSettingsUI();
146
+ })
147
+ .catch(() => {
148
+ openInEditorPluginDetected = false;
149
+ });
150
+ }
42
151
  // Render initial UI
43
152
  $el.html(buildHTML());
44
153
  wireUpButtons();
45
154
  wireUpExpanders();
46
155
  wireUpResize();
47
156
  wireUpFilterUI();
157
+ wireUpSettingsUI();
48
158
  renderLogs();
49
159
  },
50
160
  show() {
@@ -244,6 +354,29 @@ export function createGgPlugin(options, gg) {
244
354
  .gg-solo-target:hover {
245
355
  background: rgba(0,0,0,0.05);
246
356
  }
357
+ /* Clickable namespace cells with file metadata (open-in-editor) */
358
+ .gg-log-ns[data-file] {
359
+ cursor: pointer;
360
+ text-decoration: underline;
361
+ text-decoration-style: dotted;
362
+ text-underline-offset: 3px;
363
+ }
364
+ .gg-log-ns[data-file]:hover {
365
+ text-decoration-style: solid;
366
+ background: rgba(0,0,0,0.05);
367
+ }
368
+ .gg-log-ns[data-file]::after {
369
+ content: ' \u{1F4CB}';
370
+ font-size: 10px;
371
+ opacity: 0;
372
+ transition: opacity 0.1s;
373
+ }
374
+ .gg-action-open .gg-log-ns[data-file]::after {
375
+ content: ' \u{1F517}';
376
+ }
377
+ .gg-log-ns[data-file]:hover::after {
378
+ opacity: 1;
379
+ }
247
380
  .gg-details {
248
381
  grid-column: 1 / -1;
249
382
  border-top: none;
@@ -286,6 +419,107 @@ export function createGgPlugin(options, gg) {
286
419
  .gg-log-content {
287
420
  word-break: break-word;
288
421
  padding: 4px 0;
422
+ position: relative;
423
+ -webkit-user-select: text !important;
424
+ user-select: text !important;
425
+ cursor: text;
426
+ }
427
+ .gg-log-content * {
428
+ -webkit-user-select: text !important;
429
+ user-select: text !important;
430
+ }
431
+ .gg-log-diff, .gg-log-ns {
432
+ -webkit-user-select: text !important;
433
+ user-select: text !important;
434
+ }
435
+ .gg-details, .gg-details * {
436
+ -webkit-user-select: text !important;
437
+ user-select: text !important;
438
+ cursor: text;
439
+ }
440
+ /* Fast custom tooltip for src expression on primitive-only rows (no expandable objects) */
441
+ .gg-log-content[data-src] {
442
+ cursor: help;
443
+ }
444
+ /* Show icon only on primitive rows (no .gg-expand child) */
445
+ .gg-log-content[data-src]:not(:has(.gg-expand))::before {
446
+ content: '\uD83D\uDD0D';
447
+ font-size: 10px;
448
+ margin-right: 4px;
449
+ opacity: 0.4;
450
+ }
451
+ .gg-log-content[data-src]:not(:has(.gg-expand)):hover::before {
452
+ opacity: 1;
453
+ }
454
+ .gg-log-content[data-src]:not(:has(.gg-expand))::after {
455
+ content: attr(data-src);
456
+ position: absolute;
457
+ top: 100%;
458
+ left: 0;
459
+ background: #333;
460
+ color: #fff;
461
+ font-size: 11px;
462
+ font-family: monospace;
463
+ padding: 3px 8px;
464
+ border-radius: 3px;
465
+ white-space: nowrap;
466
+ pointer-events: none;
467
+ opacity: 0;
468
+ transition: opacity 0.1s;
469
+ z-index: 1000;
470
+ max-width: 90vw;
471
+ overflow: hidden;
472
+ text-overflow: ellipsis;
473
+ }
474
+ .gg-log-content[data-src]:not(:has(.gg-expand)):hover::after {
475
+ opacity: 1;
476
+ }
477
+ /* Expression icon inline with expandable object labels */
478
+ .gg-src-icon {
479
+ font-size: 10px;
480
+ margin-right: 2px;
481
+ opacity: 0.4;
482
+ cursor: pointer;
483
+ }
484
+ .gg-expand:hover .gg-src-icon,
485
+ .gg-src-icon:hover {
486
+ opacity: 1;
487
+ }
488
+ /* Hover tooltip for expandable objects/arrays */
489
+ .gg-hover-tooltip {
490
+ display: none;
491
+ position: fixed;
492
+ background: #1e1e1e;
493
+ color: #d4d4d4;
494
+ font-size: 11px;
495
+ font-family: monospace;
496
+ padding: 8px 10px;
497
+ border-radius: 4px;
498
+ white-space: pre;
499
+ pointer-events: none;
500
+ z-index: 100000;
501
+ max-width: min(90vw, 500px);
502
+ max-height: 300px;
503
+ overflow: auto;
504
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
505
+ line-height: 1.4;
506
+ }
507
+ .gg-hover-tooltip-src {
508
+ color: #9cdcfe;
509
+ font-style: italic;
510
+ margin-bottom: 4px;
511
+ padding-bottom: 4px;
512
+ border-bottom: 1px solid #444;
513
+ }
514
+ /* Expression header inside expanded details */
515
+ .gg-details-src {
516
+ color: #555;
517
+ font-style: italic;
518
+ font-family: monospace;
519
+ font-size: 11px;
520
+ margin-bottom: 6px;
521
+ padding-bottom: 4px;
522
+ border-bottom: 1px solid #ddd;
289
523
  }
290
524
  .gg-filter-panel {
291
525
  background: #f5f5f5;
@@ -313,15 +547,88 @@ export function createGgPlugin(options, gg) {
313
547
  max-height: 100px;
314
548
  overflow-y: auto;
315
549
  }
316
- .gg-filter-checkbox {
317
- display: flex;
318
- align-items: center;
319
- gap: 4px;
320
- font-size: 11px;
321
- font-family: monospace;
322
- white-space: nowrap;
323
- }
324
- /* Mobile responsive styles */
550
+ .gg-filter-checkbox {
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 4px;
554
+ font-size: 11px;
555
+ font-family: monospace;
556
+ white-space: nowrap;
557
+ }
558
+ .gg-settings-panel {
559
+ background: #f5f5f5;
560
+ padding: 10px;
561
+ margin-bottom: 8px;
562
+ border-radius: 4px;
563
+ flex-shrink: 0;
564
+ display: none;
565
+ }
566
+ .gg-settings-panel.expanded {
567
+ display: block;
568
+ }
569
+ .gg-settings-label {
570
+ font-size: 11px;
571
+ font-weight: bold;
572
+ margin-bottom: 4px;
573
+ }
574
+ .gg-editor-format-input,
575
+ .gg-project-root-input {
576
+ width: 100%;
577
+ padding: 4px 8px;
578
+ font-family: monospace;
579
+ font-size: 14px;
580
+ margin-bottom: 8px;
581
+ box-sizing: border-box;
582
+ }
583
+ .gg-editor-presets {
584
+ display: flex;
585
+ flex-wrap: wrap;
586
+ gap: 4px;
587
+ }
588
+ .gg-editor-presets button {
589
+ padding: 2px 8px;
590
+ font-size: 11px;
591
+ cursor: pointer;
592
+ border: 1px solid #ccc;
593
+ border-radius: 3px;
594
+ background: #fff;
595
+ }
596
+ .gg-editor-presets button.active {
597
+ background: #4a9eff;
598
+ color: #fff;
599
+ border-color: #4a9eff;
600
+ }
601
+ .gg-editor-presets button:hover {
602
+ background: #e0e0e0;
603
+ }
604
+ .gg-editor-presets button.active:hover {
605
+ background: #3a8eef;
606
+ }
607
+ .gg-settings-radios {
608
+ display: flex;
609
+ flex-wrap: wrap;
610
+ gap: 4px 12px;
611
+ margin-bottom: 8px;
612
+ }
613
+ .gg-settings-radios label {
614
+ font-size: 12px;
615
+ padding: 3px 0;
616
+ cursor: pointer;
617
+ white-space: nowrap;
618
+ }
619
+ .gg-settings-radios label.disabled {
620
+ opacity: 0.4;
621
+ cursor: not-allowed;
622
+ }
623
+ .gg-settings-sub {
624
+ margin-top: 4px;
625
+ margin-bottom: 8px;
626
+ }
627
+ .gg-settings-sub select {
628
+ padding: 2px 4px;
629
+ font-size: 12px;
630
+ }
631
+ /* Mobile responsive styles */
325
632
  .gg-toolbar {
326
633
  display: flex;
327
634
  align-items: center;
@@ -410,7 +717,7 @@ export function createGgPlugin(options, gg) {
410
717
  }
411
718
  }
412
719
  </style>
413
- <div class="eruda-gg" style="padding: 10px; height: 100%; display: flex; flex-direction: column; font-size: 14px; touch-action: none; overscroll-behavior: contain;">
720
+ <div class="eruda-gg${nsClickAction === 'open' || nsClickAction === 'open-url' ? ' gg-action-open' : ''}" style="padding: 10px; height: 100%; display: flex; flex-direction: column; font-size: 14px; touch-action: none; overscroll-behavior: contain;">
414
721
  <div class="gg-toolbar">
415
722
  <button class="gg-copy-btn">
416
723
  <span class="gg-btn-text">Copy</span>
@@ -421,6 +728,10 @@ export function createGgPlugin(options, gg) {
421
728
  <span class="gg-btn-icon">NS: </span>
422
729
  <span class="gg-filter-summary"></span>
423
730
  </button>
731
+ <button class="gg-settings-btn">
732
+ <span class="gg-btn-text">⚙️ Settings</span>
733
+ <span class="gg-btn-icon" title="Settings">⚙️</span>
734
+ </button>
424
735
  <span class="gg-count" style="opacity: 0.6; white-space: nowrap; flex: 1; text-align: right;"></span>
425
736
  <button class="gg-clear-btn">
426
737
  <span class="gg-btn-text">Clear</span>
@@ -428,13 +739,15 @@ export function createGgPlugin(options, gg) {
428
739
  </button>
429
740
  </div>
430
741
  <div class="gg-filter-panel"></div>
742
+ <div class="gg-settings-panel"></div>
431
743
  <div class="gg-log-container" style="flex: 1; overflow-y: auto; font-family: monospace; font-size: 12px; touch-action: pan-y; overscroll-behavior: contain;"></div>
744
+ <iframe class="gg-editor-iframe" hidden title="open-in-editor"></iframe>
432
745
  </div>
433
746
  `;
434
747
  }
435
748
  function applyPatternFromInput(value) {
436
749
  filterPattern = value;
437
- localStorage.setItem('debug', filterPattern);
750
+ localStorage.setItem(FILTER_KEY, filterPattern);
438
751
  // Sync enabledNamespaces from the new pattern
439
752
  const allNamespaces = getAllCapturedNamespaces();
440
753
  enabledNamespaces.clear();
@@ -455,9 +768,13 @@ export function createGgPlugin(options, gg) {
455
768
  if (!filterBtn || !filterPanel)
456
769
  return;
457
770
  renderFilterUI();
458
- // Wire up button toggle
771
+ // Wire up button toggle (close settings if opening filter)
459
772
  filterBtn.addEventListener('click', () => {
460
773
  filterExpanded = !filterExpanded;
774
+ if (filterExpanded) {
775
+ settingsExpanded = false;
776
+ renderSettingsUI();
777
+ }
461
778
  renderFilterUI();
462
779
  renderLogs(); // Re-render to update grid columns
463
780
  });
@@ -493,7 +810,7 @@ export function createGgPlugin(options, gg) {
493
810
  filterPattern = `gg:*,${exclusions}`;
494
811
  enabledNamespaces.clear();
495
812
  }
496
- localStorage.setItem('debug', filterPattern);
813
+ localStorage.setItem(FILTER_KEY, filterPattern);
497
814
  renderFilterUI();
498
815
  renderLogs();
499
816
  return;
@@ -572,6 +889,214 @@ export function createGgPlugin(options, gg) {
572
889
  filterPanel.classList.remove('expanded');
573
890
  }
574
891
  }
892
+ /** Render the format field + $ROOT field shared by "Copy to clipboard" and "Open as URL" */
893
+ function renderFormatSection(isUrlMode) {
894
+ const presets = isUrlMode ? uriPresets : copyPresets;
895
+ const placeholder = isUrlMode ? 'vscode://file/$ROOT/$FILE:$LINE:$COL' : '$FILE:$LINE:$COL';
896
+ const description = isUrlMode
897
+ ? 'Opens a URI in the browser. Editor apps register URI schemes to handle these.'
898
+ : 'Copies a command to your clipboard. Paste in a terminal to open the source file.';
899
+ const currentFormat = activeFormat();
900
+ const presetButtons = Object.entries(presets)
901
+ .map(([name, fmt]) => {
902
+ const active = currentFormat === fmt ? ' active' : '';
903
+ return `<button class="gg-preset-btn${active}" data-format="${escapeHtml(fmt)}">${escapeHtml(name)}</button>`;
904
+ })
905
+ .join('');
906
+ return `
907
+ <div class="gg-settings-sub">
908
+ <div style="font-size: 11px; opacity: 0.7; margin-bottom: 6px;">${description}<br>Variables: <code>$FILE</code>, <code>$LINE</code>, <code>$COL</code>, <code>$ROOT</code></div>
909
+ <input type="text" class="gg-editor-format-input" value="${escapeHtml(currentFormat)}" placeholder="${escapeHtml(placeholder)}">
910
+ <div class="gg-settings-label" style="margin-top: 4px;">Presets:</div>
911
+ <div class="gg-editor-presets">${presetButtons}</div>
912
+ <div style="margin-top: 8px;">
913
+ <div class="gg-settings-label">Project root (<code>$ROOT</code>):</div>
914
+ <input type="text" class="gg-project-root-input" value="${escapeHtml(projectRoot)}" placeholder="/home/user/my-project" style="width: 100%; padding: 4px 8px; font-family: monospace; font-size: 14px; box-sizing: border-box;">
915
+ <div style="font-size: 10px; opacity: 0.5; margin-top: 2px;">${DEV && openInEditorPluginDetected ? 'Auto-filled from dev server.' : 'Set manually for URI schemes.'} Uses forward slashes on all platforms.</div>
916
+ </div>
917
+ </div>
918
+ `;
919
+ }
920
+ function renderSettingsUI() {
921
+ if (!$el)
922
+ return;
923
+ const settingsPanel = $el.find('.gg-settings-panel').get(0);
924
+ if (!settingsPanel)
925
+ return;
926
+ // Toggle CSS class on container for hover icon (📝 vs 📋)
927
+ const container = $el.find('.eruda-gg').get(0);
928
+ if (container) {
929
+ container.classList.toggle('gg-action-open', nsClickAction === 'open' || nsClickAction === 'open-url');
930
+ }
931
+ if (settingsExpanded) {
932
+ settingsPanel.classList.add('expanded');
933
+ const openDisabled = !DEV;
934
+ const openLabelClass = openDisabled ? ' disabled' : '';
935
+ // Warning when call-sites plugin not installed (no file/line/col metadata)
936
+ const callSitesWarning = !_ggCallSitesPlugin
937
+ ? `<div style="font-size: 11px; color: #b8860b; margin-bottom: 6px;">\u26A0 call-sites vite plugin not detected \u2014 namespaces have no file locations. Add ggCallSitesPlugin() to vite.config.ts to enable click-to-open.</div>`
938
+ : '';
939
+ // Options section below all radios (editor buttons or format field)
940
+ let optionsSection = '';
941
+ if (nsClickAction === 'open' && !openDisabled) {
942
+ const pluginWarning = openInEditorPluginDetected === false
943
+ ? `<div style="font-size: 11px; color: #b8860b; margin-bottom: 6px;">\u26A0 open-in-editor vite plugin not detected \u2014 file will open at line 1 (no cursor positioning or editor selection). Add openInEditorPlugin() to vite.config.ts for full support.</div>`
944
+ : '';
945
+ let editorButtons = '';
946
+ if (openInEditorPluginDetected !== false) {
947
+ const buttons = editorBins
948
+ .map(({ label, value }) => {
949
+ const active = value === editorBin ? ' active' : '';
950
+ return `<button class="gg-editor-bin-btn${active}" data-editor="${escapeHtml(value)}">${escapeHtml(label)}</button>`;
951
+ })
952
+ .join('');
953
+ editorButtons = `<div class="gg-settings-label">Editor:</div><div class="gg-editor-presets">${buttons}</div>`;
954
+ }
955
+ optionsSection = `<div class="gg-settings-sub">${pluginWarning}${editorButtons}</div>`;
956
+ }
957
+ else if (nsClickAction === 'copy' || nsClickAction === 'open-url') {
958
+ optionsSection = renderFormatSection(nsClickAction === 'open-url');
959
+ }
960
+ // Native Console section
961
+ const currentDebugValue = localStorage.getItem('debug');
962
+ const debugDisplay = currentDebugValue !== null ? `'${escapeHtml(currentDebugValue)}'` : 'not set';
963
+ const currentFilter = filterPattern || 'gg:*';
964
+ const debugMatchesFilter = currentDebugValue === currentFilter;
965
+ const debugIncludesGg = currentDebugValue !== null &&
966
+ (currentDebugValue.includes('gg:') || currentDebugValue === '*');
967
+ const nativeConsoleSection = `
968
+ <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #ddd;">
969
+ <div class="gg-settings-label">Native Console Output</div>
970
+ <div style="font-size: 11px; opacity: 0.7; margin-bottom: 8px;">
971
+ gg messages always appear in this GG panel. To also see them in the browser's native console, set <code>localStorage.debug</code> below.
972
+ For server-side: <code>DEBUG=gg:* npm run dev</code>
973
+ </div>
974
+ <div style="font-family: monospace; font-size: 12px; margin-bottom: 6px;">
975
+ localStorage.debug = ${debugDisplay}
976
+ ${debugIncludesGg ? '<span style="color: green;">✅</span>' : '<span style="color: #999;">⚫ gg:* not included</span>'}
977
+ </div>
978
+ <div style="display: flex; gap: 6px; flex-wrap: wrap;">
979
+ <button class="gg-sync-debug-btn" style="padding: 4px 10px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 3px; background: ${debugMatchesFilter ? '#e8e8e8' : '#fff'};"${debugMatchesFilter ? ' disabled' : ''}>
980
+ ${debugMatchesFilter ? 'In sync' : `Set to '${escapeHtml(currentFilter)}'`}
981
+ </button>
982
+ <button class="gg-clear-debug-btn" style="padding: 4px 10px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 3px; background: #fff;"${currentDebugValue === null ? ' disabled' : ''}>
983
+ Clear
984
+ </button>
985
+ </div>
986
+ <div style="font-size: 10px; opacity: 0.5; margin-top: 4px;">
987
+ Changes take effect on next page reload.
988
+ </div>
989
+ </div>
990
+ `;
991
+ settingsPanel.innerHTML = `
992
+ ${callSitesWarning}
993
+ <div class="gg-settings-label">When namespace clicked:</div>
994
+ <div class="gg-settings-radios">
995
+ <label class="${openLabelClass}">
996
+ <input type="radio" name="gg-ns-action" value="open" ${nsClickAction === 'open' ? 'checked' : ''} ${openDisabled ? 'disabled' : ''}>
997
+ Open via dev server${openDisabled ? ' (dev mode only)' : ''}
998
+ </label>
999
+ <label>
1000
+ <input type="radio" name="gg-ns-action" value="open-url" ${nsClickAction === 'open-url' ? 'checked' : ''}>
1001
+ Open via URL
1002
+ </label>
1003
+ <label>
1004
+ <input type="radio" name="gg-ns-action" value="copy" ${nsClickAction === 'copy' ? 'checked' : ''}>
1005
+ Copy to clipboard
1006
+ </label>
1007
+ </div>
1008
+ ${optionsSection}
1009
+ ${nativeConsoleSection}
1010
+ `;
1011
+ }
1012
+ else {
1013
+ settingsPanel.classList.remove('expanded');
1014
+ }
1015
+ }
1016
+ function wireUpSettingsUI() {
1017
+ if (!$el)
1018
+ return;
1019
+ const settingsBtn = $el.find('.gg-settings-btn').get(0);
1020
+ const settingsPanel = $el.find('.gg-settings-panel').get(0);
1021
+ if (!settingsBtn || !settingsPanel)
1022
+ return;
1023
+ // Toggle settings panel (close filter if opening settings)
1024
+ settingsBtn.addEventListener('click', () => {
1025
+ settingsExpanded = !settingsExpanded;
1026
+ if (settingsExpanded) {
1027
+ filterExpanded = false;
1028
+ renderFilterUI();
1029
+ renderLogs();
1030
+ }
1031
+ renderSettingsUI();
1032
+ });
1033
+ // Event delegation on settings panel
1034
+ settingsPanel.addEventListener('change', (e) => {
1035
+ const target = e.target;
1036
+ // Radio buttons: open vs copy vs open-url
1037
+ if (target.name === 'gg-ns-action') {
1038
+ nsClickAction = target.value;
1039
+ localStorage.setItem(NS_ACTION_KEY, nsClickAction);
1040
+ renderSettingsUI();
1041
+ renderLogs(); // Re-render tooltips
1042
+ }
1043
+ });
1044
+ // Format + project root inputs: apply on blur or Enter
1045
+ settingsPanel.addEventListener('blur', (e) => {
1046
+ const target = e.target;
1047
+ if (target.classList.contains('gg-editor-format-input')) {
1048
+ setActiveFormat(target.value);
1049
+ renderSettingsUI();
1050
+ }
1051
+ if (target.classList.contains('gg-project-root-input')) {
1052
+ projectRoot = target.value;
1053
+ localStorage.setItem(PROJECT_ROOT_KEY, projectRoot);
1054
+ }
1055
+ }, true);
1056
+ settingsPanel.addEventListener('keydown', (e) => {
1057
+ const target = e.target;
1058
+ if (target.classList.contains('gg-editor-format-input') && e.key === 'Enter') {
1059
+ setActiveFormat(target.value);
1060
+ target.blur();
1061
+ renderSettingsUI();
1062
+ }
1063
+ if (target.classList.contains('gg-project-root-input') && e.key === 'Enter') {
1064
+ projectRoot = target.value;
1065
+ localStorage.setItem(PROJECT_ROOT_KEY, projectRoot);
1066
+ target.blur();
1067
+ }
1068
+ });
1069
+ // Preset button clicks + editor bin button clicks + native console buttons
1070
+ settingsPanel.addEventListener('click', (e) => {
1071
+ const target = e.target;
1072
+ if (target.classList.contains('gg-preset-btn')) {
1073
+ const fmt = target.getAttribute('data-format');
1074
+ if (fmt) {
1075
+ setActiveFormat(fmt);
1076
+ renderSettingsUI();
1077
+ }
1078
+ }
1079
+ if (target.classList.contains('gg-editor-bin-btn')) {
1080
+ const editor = target.getAttribute('data-editor');
1081
+ if (editor !== null) {
1082
+ editorBin = editor;
1083
+ localStorage.setItem(EDITOR_BIN_KEY, editorBin);
1084
+ renderSettingsUI();
1085
+ }
1086
+ }
1087
+ // Native Console: sync localStorage.debug to current gg-filter
1088
+ if (target.classList.contains('gg-sync-debug-btn')) {
1089
+ const currentFilter = localStorage.getItem(FILTER_KEY) || 'gg:*';
1090
+ localStorage.setItem('debug', currentFilter);
1091
+ renderSettingsUI();
1092
+ }
1093
+ // Native Console: clear localStorage.debug
1094
+ if (target.classList.contains('gg-clear-debug-btn')) {
1095
+ localStorage.removeItem('debug');
1096
+ renderSettingsUI();
1097
+ }
1098
+ });
1099
+ }
575
1100
  function wireUpButtons() {
576
1101
  if (!$el)
577
1102
  return;
@@ -616,6 +1141,58 @@ export function createGgPlugin(options, gg) {
616
1141
  }
617
1142
  });
618
1143
  }
1144
+ /** Substitute format variables ($ROOT, $FILE, $LINE, $COL) in a format string */
1145
+ function formatString(format, file, line, col) {
1146
+ return format
1147
+ .replace(/\$ROOT/g, projectRoot)
1148
+ .replace(/\$FILE/g, file)
1149
+ .replace(/\$LINE/g, line || '1')
1150
+ .replace(/\$COL/g, col || '1');
1151
+ }
1152
+ /**
1153
+ * Handle namespace click: open in editor via Vite middleware, copy to clipboard, or open URL.
1154
+ * Behavior is controlled by the nsClickAction setting.
1155
+ */
1156
+ function handleNamespaceClick(target) {
1157
+ if (!$el)
1158
+ return;
1159
+ const file = target.getAttribute('data-file');
1160
+ if (!file)
1161
+ return;
1162
+ const line = target.getAttribute('data-line');
1163
+ const col = target.getAttribute('data-col');
1164
+ if (nsClickAction === 'open' && DEV) {
1165
+ // Open in editor via Vite dev server middleware
1166
+ let url = `/__open-in-editor?file=${encodeURIComponent(file)}`;
1167
+ if (line)
1168
+ url += `&line=${line}`;
1169
+ if (line && col)
1170
+ url += `&col=${col}`;
1171
+ if (editorBin)
1172
+ url += `&editor=${encodeURIComponent(editorBin)}`;
1173
+ const iframe = $el.find('.gg-editor-iframe').get(0);
1174
+ if (iframe) {
1175
+ iframe.src = url;
1176
+ }
1177
+ }
1178
+ else if (nsClickAction === 'open-url') {
1179
+ // Open formatted URI in browser (editor handles the URI scheme)
1180
+ const formatted = formatString(activeFormat(), file, line, col);
1181
+ window.open(formatted, '_blank');
1182
+ }
1183
+ else {
1184
+ // Copy formatted file path to clipboard
1185
+ const formatted = formatString(activeFormat(), file, line, col);
1186
+ navigator.clipboard.writeText(formatted).then(() => {
1187
+ // Brief "Copied!" feedback on the namespace cell
1188
+ const original = target.textContent;
1189
+ target.textContent = '\u{1F4CB} Copied!';
1190
+ setTimeout(() => {
1191
+ target.textContent = original;
1192
+ }, 1200);
1193
+ });
1194
+ }
1195
+ }
619
1196
  function wireUpExpanders() {
620
1197
  if (!$el || expanderAttached)
621
1198
  return;
@@ -637,6 +1214,13 @@ export function createGgPlugin(options, gg) {
637
1214
  }
638
1215
  return;
639
1216
  }
1217
+ // Handle clicking namespace to open in editor (when filter collapsed)
1218
+ if (target?.classList?.contains('gg-log-ns') &&
1219
+ target.hasAttribute('data-file') &&
1220
+ !target.classList.contains('gg-solo-target')) {
1221
+ handleNamespaceClick(target);
1222
+ return;
1223
+ }
640
1224
  // Handle filter icon clicks (hide / solo)
641
1225
  if (target?.classList?.contains('gg-icon-hide') ||
642
1226
  target?.classList?.contains('gg-icon-solo')) {
@@ -650,7 +1234,7 @@ export function createGgPlugin(options, gg) {
650
1234
  else {
651
1235
  soloNamespace(namespace);
652
1236
  }
653
- localStorage.setItem('debug', filterPattern);
1237
+ localStorage.setItem(FILTER_KEY, filterPattern);
654
1238
  renderFilterUI();
655
1239
  renderLogs();
656
1240
  return;
@@ -661,7 +1245,7 @@ export function createGgPlugin(options, gg) {
661
1245
  if (!namespace)
662
1246
  return;
663
1247
  soloNamespace(namespace);
664
- localStorage.setItem('debug', filterPattern);
1248
+ localStorage.setItem(FILTER_KEY, filterPattern);
665
1249
  renderFilterUI();
666
1250
  renderLogs();
667
1251
  return;
@@ -673,11 +1257,75 @@ export function createGgPlugin(options, gg) {
673
1257
  filterPattern = 'gg:*';
674
1258
  enabledNamespaces.clear();
675
1259
  getAllCapturedNamespaces().forEach((ns) => enabledNamespaces.add(ns));
676
- localStorage.setItem('debug', filterPattern);
1260
+ localStorage.setItem(FILTER_KEY, filterPattern);
677
1261
  renderFilterUI();
678
1262
  renderLogs();
679
1263
  }
680
1264
  });
1265
+ // Hover tooltip for expandable objects/arrays.
1266
+ // The tooltip div is re-created after each renderLogs() call
1267
+ // since logContainer.html() destroys children. Event listeners query it dynamically.
1268
+ containerEl.addEventListener('mouseover', (e) => {
1269
+ const target = e.target?.closest?.('.gg-expand');
1270
+ if (!target)
1271
+ return;
1272
+ const entryIdx = target.getAttribute('data-entry');
1273
+ const argIdx = target.getAttribute('data-arg');
1274
+ if (entryIdx === null || argIdx === null)
1275
+ return;
1276
+ const entry = renderedEntries[Number(entryIdx)];
1277
+ if (!entry)
1278
+ return;
1279
+ const arg = entry.args[Number(argIdx)];
1280
+ if (arg === undefined)
1281
+ return;
1282
+ const tip = containerEl.querySelector('.gg-hover-tooltip');
1283
+ if (!tip)
1284
+ return;
1285
+ const srcExpr = target.getAttribute('data-src');
1286
+ // Build tooltip content using DOM API (safe, no HTML injection)
1287
+ tip.textContent = '';
1288
+ if (srcExpr) {
1289
+ const srcDiv = document.createElement('div');
1290
+ srcDiv.className = 'gg-hover-tooltip-src';
1291
+ srcDiv.textContent = srcExpr;
1292
+ tip.appendChild(srcDiv);
1293
+ }
1294
+ const pre = document.createElement('pre');
1295
+ pre.style.margin = '0';
1296
+ pre.textContent = JSON.stringify(arg, null, 2);
1297
+ tip.appendChild(pre);
1298
+ tip.style.display = 'block';
1299
+ // Position below the hovered element using viewport coords (fixed positioning)
1300
+ const targetRect = target.getBoundingClientRect();
1301
+ let left = targetRect.left;
1302
+ let top = targetRect.bottom + 4;
1303
+ // Keep tooltip within viewport
1304
+ const tipRect = tip.getBoundingClientRect();
1305
+ if (left + tipRect.width > window.innerWidth) {
1306
+ left = window.innerWidth - tipRect.width - 8;
1307
+ }
1308
+ if (left < 4)
1309
+ left = 4;
1310
+ // If tooltip would go below viewport, show above instead
1311
+ if (top + tipRect.height > window.innerHeight) {
1312
+ top = targetRect.top - tipRect.height - 4;
1313
+ }
1314
+ tip.style.left = `${left}px`;
1315
+ tip.style.top = `${top}px`;
1316
+ });
1317
+ containerEl.addEventListener('mouseout', (e) => {
1318
+ const target = e.target?.closest?.('.gg-expand');
1319
+ if (!target)
1320
+ return;
1321
+ // Only hide if we're not moving to another child of the same .gg-expand
1322
+ const related = e.relatedTarget;
1323
+ if (related?.closest?.('.gg-expand') === target)
1324
+ return;
1325
+ const tip = containerEl.querySelector('.gg-hover-tooltip');
1326
+ if (tip)
1327
+ tip.style.display = 'none';
1328
+ });
681
1329
  expanderAttached = true;
682
1330
  }
683
1331
  function wireUpResize() {
@@ -737,6 +1385,7 @@ export function createGgPlugin(options, gg) {
737
1385
  const allEntries = buffer.getEntries();
738
1386
  // Apply filtering
739
1387
  const entries = allEntries.filter((entry) => enabledNamespaces.has(entry.namespace));
1388
+ renderedEntries = entries;
740
1389
  const countText = entries.length === allEntries.length
741
1390
  ? `${entries.length} entries`
742
1391
  : `${entries.length} / ${allEntries.length} entries`;
@@ -753,6 +1402,8 @@ export function createGgPlugin(options, gg) {
753
1402
  // Format each arg individually - objects are expandable
754
1403
  let argsHTML = '';
755
1404
  let detailsHTML = '';
1405
+ // Source expression for this entry (used in hover tooltips and expanded details)
1406
+ const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
756
1407
  if (entry.args.length > 0) {
757
1408
  argsHTML = entry.args
758
1409
  .map((arg, argIdx) => {
@@ -761,9 +1412,14 @@ export function createGgPlugin(options, gg) {
761
1412
  const preview = Array.isArray(arg) ? `Array(${arg.length})` : 'Object';
762
1413
  const jsonStr = escapeHtml(JSON.stringify(arg, null, 2));
763
1414
  const uniqueId = `${index}-${argIdx}`;
1415
+ // Expression header inside expanded details
1416
+ const srcHeader = srcExpr ? `<div class="gg-details-src">${srcExpr}</div>` : '';
764
1417
  // Store details separately to render after the row
765
- detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: none; margin: 4px 0 8px 0; padding: 8px; background: #f8f8f8; border-left: 3px solid ${color}; font-size: 11px; overflow-x: auto;"><pre style="margin: 0;">${jsonStr}</pre></div>`;
766
- return `<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}">${preview}</span>`;
1418
+ detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: none; margin: 4px 0 8px 0; padding: 8px; background: #f8f8f8; border-left: 3px solid ${color}; font-size: 11px; overflow-x: auto;">${srcHeader}<pre style="margin: 0;">${jsonStr}</pre></div>`;
1419
+ // data-entry/data-arg for hover tooltip lookup, data-src for expression context
1420
+ const srcAttr = srcExpr ? ` data-src="${srcExpr}"` : '';
1421
+ const srcIcon = srcExpr ? `<span class="gg-src-icon">\uD83D\uDD0D</span>` : '';
1422
+ return `<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}" data-entry="${index}" data-arg="${argIdx}"${srcAttr}>${srcIcon}${preview}</span>`;
767
1423
  }
768
1424
  else {
769
1425
  // Parse ANSI codes first, then convert URLs to clickable links
@@ -787,20 +1443,44 @@ export function createGgPlugin(options, gg) {
787
1443
  // When filter expanded, diff+ns are clickable (solo) with data-namespace
788
1444
  const soloAttr = filterExpanded ? ` data-namespace="${ns}"` : '';
789
1445
  const soloClass = filterExpanded ? ' gg-solo-target' : '';
1446
+ // Open-in-editor data attributes (file, line, col)
1447
+ const fileAttr = entry.file ? ` data-file="${escapeHtml(entry.file)}"` : '';
1448
+ const lineAttr = entry.line ? ` data-line="${entry.line}"` : '';
1449
+ const colAttr = entry.col ? ` data-col="${entry.col}"` : '';
1450
+ let fileTitleText = '';
1451
+ if (entry.file) {
1452
+ if (nsClickAction === 'open' && DEV) {
1453
+ fileTitleText = `Open in editor: ${entry.file}${entry.line ? ':' + entry.line : ''}${entry.col ? ':' + entry.col : ''}`;
1454
+ }
1455
+ else if (nsClickAction === 'open-url') {
1456
+ fileTitleText = `Open URL: ${formatString(activeFormat(), entry.file, String(entry.line || 1), String(entry.col || 1))}`;
1457
+ }
1458
+ else {
1459
+ fileTitleText = `Copy: ${formatString(activeFormat(), entry.file, String(entry.line || 1), String(entry.col || 1))}`;
1460
+ }
1461
+ }
1462
+ const fileTitle = fileTitleText ? ` title="${escapeHtml(fileTitleText)}"` : '';
790
1463
  // Desktop: grid layout, Mobile: stacked layout
791
1464
  return (`<div class="gg-log-entry">` +
792
1465
  `<div class="gg-log-header">` +
793
1466
  iconsCol +
794
1467
  `<div class="gg-log-diff${soloClass}" style="color: ${color};"${soloAttr}>${diff}</div>` +
795
- `<div class="gg-log-ns${soloClass}" style="color: ${color};"${soloAttr}>${ns}</div>` +
1468
+ `<div class="gg-log-ns${soloClass}" style="color: ${color};"${soloAttr}${fileAttr}${lineAttr}${colAttr}${fileTitle}>${ns}</div>` +
796
1469
  `<div class="gg-log-handle"></div>` +
797
1470
  `</div>` +
798
- `<div class="gg-log-content">${argsHTML}</div>` +
1471
+ `<div class="gg-log-content"${entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}</div>` +
799
1472
  detailsHTML +
800
1473
  `</div>`);
801
1474
  })
802
1475
  .join('')}</div>`;
803
1476
  logContainer.html(logsHTML);
1477
+ // Append hover tooltip div (destroyed by .html() each render, so re-create)
1478
+ const containerDom = logContainer.get(0);
1479
+ if (containerDom) {
1480
+ const tip = document.createElement('div');
1481
+ tip.className = 'gg-hover-tooltip';
1482
+ containerDom.appendChild(tip);
1483
+ }
804
1484
  // Re-wire expanders after rendering
805
1485
  wireUpExpanders();
806
1486
  // Auto-scroll to bottom
@@ -832,17 +1512,58 @@ export function createGgPlugin(options, gg) {
832
1512
  */
833
1513
  function stripAnsi(text) {
834
1514
  // Remove all ANSI escape codes
1515
+ // eslint-disable-next-line no-control-regex
835
1516
  return text.replace(/\x1b\[[0-9;]*m/g, '');
836
1517
  }
1518
+ // Standard ANSI 3/4-bit color palette
1519
+ const ANSI_COLORS = {
1520
+ // Normal foreground (30-37)
1521
+ 30: '#000000',
1522
+ 31: '#cc0000',
1523
+ 32: '#00cc00',
1524
+ 33: '#cccc00',
1525
+ 34: '#0000cc',
1526
+ 35: '#cc00cc',
1527
+ 36: '#00cccc',
1528
+ 37: '#cccccc',
1529
+ // Normal background (40-47)
1530
+ 40: '#000000',
1531
+ 41: '#cc0000',
1532
+ 42: '#00cc00',
1533
+ 43: '#cccc00',
1534
+ 44: '#0000cc',
1535
+ 45: '#cc00cc',
1536
+ 46: '#00cccc',
1537
+ 47: '#cccccc',
1538
+ // Bright foreground (90-97)
1539
+ 90: '#555555',
1540
+ 91: '#ff5555',
1541
+ 92: '#55ff55',
1542
+ 93: '#ffff55',
1543
+ 94: '#5555ff',
1544
+ 95: '#ff55ff',
1545
+ 96: '#55ffff',
1546
+ 97: '#ffffff',
1547
+ // Bright background (100-107)
1548
+ 100: '#555555',
1549
+ 101: '#ff5555',
1550
+ 102: '#55ff55',
1551
+ 103: '#ffff55',
1552
+ 104: '#5555ff',
1553
+ 105: '#ff55ff',
1554
+ 106: '#55ffff',
1555
+ 107: '#ffffff'
1556
+ };
837
1557
  /**
838
1558
  * Parse ANSI escape codes and convert to HTML with inline styles
839
1559
  * Supports:
1560
+ * - Basic 3/4-bit colors: \x1b[31m (fg red), \x1b[41m (bg red), \x1b[91m (bright fg), etc.
840
1561
  * - 24-bit RGB: \x1b[38;2;r;g;bm (foreground), \x1b[48;2;r;g;bm (background)
841
1562
  * - Reset: \x1b[0m
842
1563
  */
843
1564
  function parseAnsiToHtml(text) {
844
1565
  // ANSI escape sequence regex
845
- // Matches: \x1b[38;2;r;g;bm, \x1b[48;2;r;g;bm, \x1b[0m
1566
+ // eslint-disable-next-line no-control-regex
846
1567
  const ansiRegex = /\x1b\[([0-9;]+)m/g;
847
1568
  let html = '';
848
1569
  let lastIndex = 0;
@@ -871,6 +1592,25 @@ export function createGgPlugin(options, gg) {
871
1592
  // Background RGB: 48;2;r;g;b
872
1593
  currentBg = `rgb(${parts[2]},${parts[3]},${parts[4]})`;
873
1594
  }
1595
+ else if (parts[0] === 39) {
1596
+ // Default foreground
1597
+ currentFg = null;
1598
+ }
1599
+ else if (parts[0] === 49) {
1600
+ // Default background
1601
+ currentBg = null;
1602
+ }
1603
+ else {
1604
+ // Basic 3/4-bit colors
1605
+ for (const p of parts) {
1606
+ if ((p >= 30 && p <= 37) || (p >= 90 && p <= 97)) {
1607
+ currentFg = ANSI_COLORS[p] || null;
1608
+ }
1609
+ else if ((p >= 40 && p <= 47) || (p >= 100 && p <= 107)) {
1610
+ currentBg = ANSI_COLORS[p] || null;
1611
+ }
1612
+ }
1613
+ }
874
1614
  lastIndex = ansiRegex.lastIndex;
875
1615
  }
876
1616
  // Add remaining text