@leftium/gg 0.0.33 → 0.0.35

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,137 @@
1
+ /**
2
+ * Node.js-specific debug implementation.
3
+ *
4
+ * Output: process.stderr via util.formatWithOptions.
5
+ * Persistence: process.env.DEBUG
6
+ * Format (patched): +123ms namespace message (ANSI colored)
7
+ */
8
+ import { setup, humanize } from './common.js';
9
+ import tty from 'tty';
10
+ import util from 'util';
11
+ /**
12
+ * Basic ANSI colors (6) — used when 256-color support is not detected.
13
+ * Extended 256-color palette matches debug@4 for color-hash stability.
14
+ */
15
+ const basicColors = [6, 2, 3, 4, 5, 1];
16
+ const extendedColors = [
17
+ 20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45,
18
+ 56, 57, 62, 63, 68, 69, 74, 75, 76, 77, 78, 79, 80, 81,
19
+ 92, 93, 98, 99, 112, 113, 128, 129, 134, 135, 148, 149,
20
+ 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171,
21
+ 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201,
22
+ 202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221
23
+ ];
24
+ /** Detect 256-color support via supports-color (optional) or heuristic */
25
+ function detectColors() {
26
+ try {
27
+ // Try supports-color if available (same as debug@4)
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const supportsColor = require('supports-color');
30
+ if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
31
+ return extendedColors;
32
+ }
33
+ }
34
+ catch {
35
+ // Not installed — fall through
36
+ }
37
+ return basicColors;
38
+ }
39
+ /**
40
+ * Build inspectOpts from DEBUG_* environment variables.
41
+ * Supports: DEBUG_COLORS, DEBUG_DEPTH, DEBUG_SHOW_HIDDEN, DEBUG_HIDE_DATE
42
+ */
43
+ const inspectOpts = Object.keys(process.env)
44
+ .filter((key) => /^debug_/i.test(key))
45
+ .reduce((obj, key) => {
46
+ const prop = key
47
+ .substring(6)
48
+ .toLowerCase()
49
+ .replace(/_([a-z])/g, (_, k) => k.toUpperCase());
50
+ let val = process.env[key];
51
+ if (/^(yes|on|true|enabled)$/i.test(val))
52
+ val = true;
53
+ else if (/^(no|off|false|disabled)$/i.test(val))
54
+ val = false;
55
+ else if (val === 'null')
56
+ val = null;
57
+ else
58
+ val = Number(val);
59
+ obj[prop] = val;
60
+ return obj;
61
+ }, {});
62
+ function useColors() {
63
+ return 'colors' in inspectOpts
64
+ ? Boolean(inspectOpts.colors)
65
+ : tty.isatty(process.stderr.fd);
66
+ }
67
+ function getDate() {
68
+ if (inspectOpts.hideDate)
69
+ return '';
70
+ return new Date().toISOString() + ' ';
71
+ }
72
+ /**
73
+ * Format args with ANSI colors and gg's patched prefix order:
74
+ * +123ms namespace message
75
+ */
76
+ function formatArgs(args) {
77
+ const name = this.namespace;
78
+ const useCol = this.useColors;
79
+ if (useCol) {
80
+ const c = Number(this.color);
81
+ const colorCode = '\u001B[3' + (c < 8 ? String(c) : '8;5;' + c);
82
+ const h = ('+' + humanize(this.diff)).padStart(6);
83
+ const prefix = `${colorCode};1m${h} ${name} \u001B[0m`;
84
+ args[0] = prefix + String(args[0]).split('\n').join('\n' + prefix);
85
+ // Append empty color reset (preserves arg count parity from original debug)
86
+ args.push(colorCode + '' + '\u001B[0m');
87
+ }
88
+ else {
89
+ args[0] = getDate() + name + ' ' + args[0];
90
+ }
91
+ }
92
+ function log(...args) {
93
+ process.stderr.write(util.formatWithOptions(inspectOpts, ...args) + '\n');
94
+ }
95
+ function save(namespaces) {
96
+ if (namespaces) {
97
+ process.env.DEBUG = namespaces;
98
+ }
99
+ else {
100
+ delete process.env.DEBUG;
101
+ }
102
+ }
103
+ function load() {
104
+ return process.env.DEBUG || '';
105
+ }
106
+ function init(instance) {
107
+ // Each instance gets its own inspectOpts copy (for per-instance color override)
108
+ instance.inspectOpts = { ...inspectOpts };
109
+ }
110
+ const env = {
111
+ formatArgs,
112
+ save,
113
+ load,
114
+ useColors,
115
+ colors: detectColors(),
116
+ log,
117
+ init,
118
+ formatters: {
119
+ /** %o → util.inspect, single line */
120
+ o(v) {
121
+ const opts = this.inspectOpts || {};
122
+ opts.colors = this.useColors;
123
+ return util.inspect(v, opts)
124
+ .split('\n')
125
+ .map((str) => str.trim())
126
+ .join(' ');
127
+ },
128
+ /** %O → util.inspect, multi-line */
129
+ O(v) {
130
+ const opts = this.inspectOpts || {};
131
+ opts.colors = this.useColors;
132
+ return util.inspect(v, opts);
133
+ }
134
+ }
135
+ };
136
+ const debug = setup(env);
137
+ export default debug;
@@ -79,17 +79,6 @@ export async function loadEruda(options) {
79
79
  // Ensure tool is always visible in case user customizes
80
80
  tool: ['console', 'elements', 'network', 'resources', 'info', 'snippets', 'sources']
81
81
  });
82
- // Auto-enable localStorage.debug if requested and unset
83
- if (options.autoEnable !== false) {
84
- try {
85
- if (!localStorage.getItem('debug')) {
86
- localStorage.setItem('debug', 'gg:*');
87
- }
88
- }
89
- catch {
90
- // localStorage might not be available
91
- }
92
- }
93
82
  // Register gg plugin
94
83
  // Import gg and pass it to the plugin directly
95
84
  const { gg, runGgDiagnostics } = await import('../gg.js');
@@ -19,8 +19,12 @@ export function createGgPlugin(options, gg) {
19
19
  let filterExpanded = false;
20
20
  let filterPattern = '';
21
21
  const enabledNamespaces = new Set();
22
+ // Last rendered entries (for hover tooltip arg lookup)
23
+ let renderedEntries = [];
22
24
  // Settings UI state
23
25
  let settingsExpanded = false;
26
+ // Filter pattern persistence key (independent of localStorage.debug)
27
+ const FILTER_KEY = 'gg-filter';
24
28
  // Namespace click action: 'open' uses Vite dev middleware, 'copy' copies formatted string, 'open-url' navigates to URI
25
29
  const NS_ACTION_KEY = 'gg-ns-action';
26
30
  const EDITOR_BIN_KEY = 'gg-editor-bin';
@@ -78,13 +82,6 @@ export function createGgPlugin(options, gg) {
78
82
  };
79
83
  let nsClickAction = localStorage.getItem(NS_ACTION_KEY) || (DEV ? 'open' : 'copy');
80
84
  let editorBin = localStorage.getItem(EDITOR_BIN_KEY) || '';
81
- // Separate format strings for copy vs URL modes (each persisted independently)
82
- // Migrate from old single-key format (gg-editor-format) if present
83
- const legacyFormat = localStorage.getItem('gg-editor-format');
84
- if (legacyFormat && !localStorage.getItem(COPY_FORMAT_KEY)) {
85
- localStorage.setItem(COPY_FORMAT_KEY, legacyFormat);
86
- localStorage.removeItem('gg-editor-format');
87
- }
88
85
  let copyFormat = localStorage.getItem(COPY_FORMAT_KEY) || copyPresets['Raw path'];
89
86
  let urlFormat = localStorage.getItem(URL_FORMAT_KEY) || uriPresets['VS Code'];
90
87
  let projectRoot = localStorage.getItem(PROJECT_ROOT_KEY) || '';
@@ -107,24 +104,27 @@ export function createGgPlugin(options, gg) {
107
104
  name: 'GG',
108
105
  init($container) {
109
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:*';
110
110
  // Register the capture hook on gg
111
111
  if (gg) {
112
112
  gg._onLog = (entry) => {
113
+ // Track namespaces for filter UI update (check BEFORE pushing to buffer)
114
+ const hadNamespace = getAllCapturedNamespaces().includes(entry.namespace);
113
115
  buffer.push(entry);
114
116
  // Add new namespace to enabledNamespaces if it matches the current pattern
115
117
  const effectivePattern = filterPattern || 'gg:*';
116
118
  if (namespaceMatchesPattern(entry.namespace, effectivePattern)) {
117
119
  enabledNamespaces.add(entry.namespace);
118
120
  }
119
- // Update filter UI if expanded (new namespace may have appeared)
120
- if (filterExpanded) {
121
+ // Update filter UI if new namespace appeared (updates button summary count)
122
+ if (!hadNamespace) {
121
123
  renderFilterUI();
122
124
  }
123
125
  renderLogs();
124
126
  };
125
127
  }
126
- // Load initial filter state
127
- filterPattern = localStorage.getItem('debug') || '';
128
128
  // Probe for openInEditorPlugin (status 222) and auto-populate $ROOT in dev mode
129
129
  if (DEV) {
130
130
  fetch('/__open-in-editor?file=+')
@@ -420,21 +420,38 @@ export function createGgPlugin(options, gg) {
420
420
  word-break: break-word;
421
421
  padding: 4px 0;
422
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;
423
434
  }
424
- /* Fast custom tooltip for src expression (no delay like native title) */
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) */
425
441
  .gg-log-content[data-src] {
426
442
  cursor: help;
427
443
  }
428
- .gg-log-content[data-src]::before {
429
- content: '🔍';
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';
430
447
  font-size: 10px;
431
448
  margin-right: 4px;
432
449
  opacity: 0.4;
433
450
  }
434
- .gg-log-content[data-src]:hover::before {
451
+ .gg-log-content[data-src]:not(:has(.gg-expand)):hover::before {
435
452
  opacity: 1;
436
453
  }
437
- .gg-log-content[data-src]::after {
454
+ .gg-log-content[data-src]:not(:has(.gg-expand))::after {
438
455
  content: attr(data-src);
439
456
  position: absolute;
440
457
  top: 100%;
@@ -454,10 +471,103 @@ export function createGgPlugin(options, gg) {
454
471
  overflow: hidden;
455
472
  text-overflow: ellipsis;
456
473
  }
457
- .gg-log-content[data-src]:hover::after {
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;
523
+ }
524
+ /* Level-based styling for warn/error entries */
525
+ .gg-level-warn .gg-log-diff,
526
+ .gg-level-warn .gg-log-ns,
527
+ .gg-level-warn .gg-log-content {
528
+ background: rgba(255, 200, 0, 0.08);
529
+ }
530
+ .gg-level-warn .gg-log-content {
531
+ border-left: 3px solid #e6a700;
532
+ padding-left: 6px;
533
+ }
534
+ .gg-level-error .gg-log-diff,
535
+ .gg-level-error .gg-log-ns,
536
+ .gg-level-error .gg-log-content {
537
+ background: rgba(255, 50, 50, 0.08);
538
+ }
539
+ .gg-level-error .gg-log-content {
540
+ border-left: 3px solid #cc0000;
541
+ padding-left: 6px;
542
+ }
543
+ /* Stack trace toggle */
544
+ .gg-stack-toggle {
545
+ cursor: pointer;
546
+ font-size: 11px;
547
+ opacity: 0.6;
548
+ margin-left: 8px;
549
+ user-select: none;
550
+ }
551
+ .gg-stack-toggle:hover {
458
552
  opacity: 1;
459
553
  }
460
- .gg-filter-panel {
554
+ .gg-stack-content {
555
+ display: none;
556
+ font-size: 11px;
557
+ font-family: monospace;
558
+ white-space: pre;
559
+ padding: 6px 8px;
560
+ margin-top: 4px;
561
+ background: #f0f0f0;
562
+ border-radius: 3px;
563
+ overflow-x: auto;
564
+ color: #666;
565
+ line-height: 1.4;
566
+ }
567
+ .gg-stack-content.expanded {
568
+ display: block;
569
+ }
570
+ .gg-filter-panel {
461
571
  background: #f5f5f5;
462
572
  padding: 10px;
463
573
  margin-bottom: 8px;
@@ -683,7 +793,7 @@ export function createGgPlugin(options, gg) {
683
793
  }
684
794
  function applyPatternFromInput(value) {
685
795
  filterPattern = value;
686
- localStorage.setItem('debug', filterPattern);
796
+ localStorage.setItem(FILTER_KEY, filterPattern);
687
797
  // Sync enabledNamespaces from the new pattern
688
798
  const allNamespaces = getAllCapturedNamespaces();
689
799
  enabledNamespaces.clear();
@@ -746,7 +856,7 @@ export function createGgPlugin(options, gg) {
746
856
  filterPattern = `gg:*,${exclusions}`;
747
857
  enabledNamespaces.clear();
748
858
  }
749
- localStorage.setItem('debug', filterPattern);
859
+ localStorage.setItem(FILTER_KEY, filterPattern);
750
860
  renderFilterUI();
751
861
  renderLogs();
752
862
  return;
@@ -893,6 +1003,37 @@ export function createGgPlugin(options, gg) {
893
1003
  else if (nsClickAction === 'copy' || nsClickAction === 'open-url') {
894
1004
  optionsSection = renderFormatSection(nsClickAction === 'open-url');
895
1005
  }
1006
+ // Native Console section
1007
+ const currentDebugValue = localStorage.getItem('debug');
1008
+ const debugDisplay = currentDebugValue !== null ? `'${escapeHtml(currentDebugValue)}'` : 'not set';
1009
+ const currentFilter = filterPattern || 'gg:*';
1010
+ const debugMatchesFilter = currentDebugValue === currentFilter;
1011
+ const debugIncludesGg = currentDebugValue !== null &&
1012
+ (currentDebugValue.includes('gg:') || currentDebugValue === '*');
1013
+ const nativeConsoleSection = `
1014
+ <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #ddd;">
1015
+ <div class="gg-settings-label">Native Console Output</div>
1016
+ <div style="font-size: 11px; opacity: 0.7; margin-bottom: 8px;">
1017
+ gg messages always appear in this GG panel. To also see them in the browser's native console, set <code>localStorage.debug</code> below.
1018
+ For server-side: <code>DEBUG=gg:* npm run dev</code>
1019
+ </div>
1020
+ <div style="font-family: monospace; font-size: 12px; margin-bottom: 6px;">
1021
+ localStorage.debug = ${debugDisplay}
1022
+ ${debugIncludesGg ? '<span style="color: green;">✅</span>' : '<span style="color: #999;">⚫ gg:* not included</span>'}
1023
+ </div>
1024
+ <div style="display: flex; gap: 6px; flex-wrap: wrap;">
1025
+ <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' : ''}>
1026
+ ${debugMatchesFilter ? 'In sync' : `Set to '${escapeHtml(currentFilter)}'`}
1027
+ </button>
1028
+ <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' : ''}>
1029
+ Clear
1030
+ </button>
1031
+ </div>
1032
+ <div style="font-size: 10px; opacity: 0.5; margin-top: 4px;">
1033
+ Changes take effect on next page reload.
1034
+ </div>
1035
+ </div>
1036
+ `;
896
1037
  settingsPanel.innerHTML = `
897
1038
  ${callSitesWarning}
898
1039
  <div class="gg-settings-label">When namespace clicked:</div>
@@ -911,6 +1052,7 @@ export function createGgPlugin(options, gg) {
911
1052
  </label>
912
1053
  </div>
913
1054
  ${optionsSection}
1055
+ ${nativeConsoleSection}
914
1056
  `;
915
1057
  }
916
1058
  else {
@@ -970,7 +1112,7 @@ export function createGgPlugin(options, gg) {
970
1112
  target.blur();
971
1113
  }
972
1114
  });
973
- // Preset button clicks + editor bin button clicks
1115
+ // Preset button clicks + editor bin button clicks + native console buttons
974
1116
  settingsPanel.addEventListener('click', (e) => {
975
1117
  const target = e.target;
976
1118
  if (target.classList.contains('gg-preset-btn')) {
@@ -988,6 +1130,17 @@ export function createGgPlugin(options, gg) {
988
1130
  renderSettingsUI();
989
1131
  }
990
1132
  }
1133
+ // Native Console: sync localStorage.debug to current gg-filter
1134
+ if (target.classList.contains('gg-sync-debug-btn')) {
1135
+ const currentFilter = localStorage.getItem(FILTER_KEY) || 'gg:*';
1136
+ localStorage.setItem('debug', currentFilter);
1137
+ renderSettingsUI();
1138
+ }
1139
+ // Native Console: clear localStorage.debug
1140
+ if (target.classList.contains('gg-clear-debug-btn')) {
1141
+ localStorage.removeItem('debug');
1142
+ renderSettingsUI();
1143
+ }
991
1144
  });
992
1145
  }
993
1146
  function wireUpButtons() {
@@ -1107,6 +1260,19 @@ export function createGgPlugin(options, gg) {
1107
1260
  }
1108
1261
  return;
1109
1262
  }
1263
+ // Handle stack trace toggle
1264
+ if (target?.classList?.contains('gg-stack-toggle')) {
1265
+ const stackId = target.getAttribute('data-stack-id');
1266
+ if (!stackId)
1267
+ return;
1268
+ const stackEl = containerEl.querySelector(`.gg-stack-content[data-stack-id="${stackId}"]`);
1269
+ if (stackEl) {
1270
+ const isExpanded = stackEl.classList.contains('expanded');
1271
+ stackEl.classList.toggle('expanded');
1272
+ target.textContent = isExpanded ? '▶ stack' : '▼ stack';
1273
+ }
1274
+ return;
1275
+ }
1110
1276
  // Handle clicking namespace to open in editor (when filter collapsed)
1111
1277
  if (target?.classList?.contains('gg-log-ns') &&
1112
1278
  target.hasAttribute('data-file') &&
@@ -1127,7 +1293,7 @@ export function createGgPlugin(options, gg) {
1127
1293
  else {
1128
1294
  soloNamespace(namespace);
1129
1295
  }
1130
- localStorage.setItem('debug', filterPattern);
1296
+ localStorage.setItem(FILTER_KEY, filterPattern);
1131
1297
  renderFilterUI();
1132
1298
  renderLogs();
1133
1299
  return;
@@ -1138,7 +1304,7 @@ export function createGgPlugin(options, gg) {
1138
1304
  if (!namespace)
1139
1305
  return;
1140
1306
  soloNamespace(namespace);
1141
- localStorage.setItem('debug', filterPattern);
1307
+ localStorage.setItem(FILTER_KEY, filterPattern);
1142
1308
  renderFilterUI();
1143
1309
  renderLogs();
1144
1310
  return;
@@ -1150,11 +1316,75 @@ export function createGgPlugin(options, gg) {
1150
1316
  filterPattern = 'gg:*';
1151
1317
  enabledNamespaces.clear();
1152
1318
  getAllCapturedNamespaces().forEach((ns) => enabledNamespaces.add(ns));
1153
- localStorage.setItem('debug', filterPattern);
1319
+ localStorage.setItem(FILTER_KEY, filterPattern);
1154
1320
  renderFilterUI();
1155
1321
  renderLogs();
1156
1322
  }
1157
1323
  });
1324
+ // Hover tooltip for expandable objects/arrays.
1325
+ // The tooltip div is re-created after each renderLogs() call
1326
+ // since logContainer.html() destroys children. Event listeners query it dynamically.
1327
+ containerEl.addEventListener('mouseover', (e) => {
1328
+ const target = e.target?.closest?.('.gg-expand');
1329
+ if (!target)
1330
+ return;
1331
+ const entryIdx = target.getAttribute('data-entry');
1332
+ const argIdx = target.getAttribute('data-arg');
1333
+ if (entryIdx === null || argIdx === null)
1334
+ return;
1335
+ const entry = renderedEntries[Number(entryIdx)];
1336
+ if (!entry)
1337
+ return;
1338
+ const arg = entry.args[Number(argIdx)];
1339
+ if (arg === undefined)
1340
+ return;
1341
+ const tip = containerEl.querySelector('.gg-hover-tooltip');
1342
+ if (!tip)
1343
+ return;
1344
+ const srcExpr = target.getAttribute('data-src');
1345
+ // Build tooltip content using DOM API (safe, no HTML injection)
1346
+ tip.textContent = '';
1347
+ if (srcExpr) {
1348
+ const srcDiv = document.createElement('div');
1349
+ srcDiv.className = 'gg-hover-tooltip-src';
1350
+ srcDiv.textContent = srcExpr;
1351
+ tip.appendChild(srcDiv);
1352
+ }
1353
+ const pre = document.createElement('pre');
1354
+ pre.style.margin = '0';
1355
+ pre.textContent = JSON.stringify(arg, null, 2);
1356
+ tip.appendChild(pre);
1357
+ tip.style.display = 'block';
1358
+ // Position below the hovered element using viewport coords (fixed positioning)
1359
+ const targetRect = target.getBoundingClientRect();
1360
+ let left = targetRect.left;
1361
+ let top = targetRect.bottom + 4;
1362
+ // Keep tooltip within viewport
1363
+ const tipRect = tip.getBoundingClientRect();
1364
+ if (left + tipRect.width > window.innerWidth) {
1365
+ left = window.innerWidth - tipRect.width - 8;
1366
+ }
1367
+ if (left < 4)
1368
+ left = 4;
1369
+ // If tooltip would go below viewport, show above instead
1370
+ if (top + tipRect.height > window.innerHeight) {
1371
+ top = targetRect.top - tipRect.height - 4;
1372
+ }
1373
+ tip.style.left = `${left}px`;
1374
+ tip.style.top = `${top}px`;
1375
+ });
1376
+ containerEl.addEventListener('mouseout', (e) => {
1377
+ const target = e.target?.closest?.('.gg-expand');
1378
+ if (!target)
1379
+ return;
1380
+ // Only hide if we're not moving to another child of the same .gg-expand
1381
+ const related = e.relatedTarget;
1382
+ if (related?.closest?.('.gg-expand') === target)
1383
+ return;
1384
+ const tip = containerEl.querySelector('.gg-hover-tooltip');
1385
+ if (tip)
1386
+ tip.style.display = 'none';
1387
+ });
1158
1388
  expanderAttached = true;
1159
1389
  }
1160
1390
  function wireUpResize() {
@@ -1214,6 +1444,7 @@ export function createGgPlugin(options, gg) {
1214
1444
  const allEntries = buffer.getEntries();
1215
1445
  // Apply filtering
1216
1446
  const entries = allEntries.filter((entry) => enabledNamespaces.has(entry.namespace));
1447
+ renderedEntries = entries;
1217
1448
  const countText = entries.length === allEntries.length
1218
1449
  ? `${entries.length} entries`
1219
1450
  : `${entries.length} / ${allEntries.length} entries`;
@@ -1230,7 +1461,29 @@ export function createGgPlugin(options, gg) {
1230
1461
  // Format each arg individually - objects are expandable
1231
1462
  let argsHTML = '';
1232
1463
  let detailsHTML = '';
1233
- if (entry.args.length > 0) {
1464
+ // Source expression for this entry (used in hover tooltips and expanded details)
1465
+ const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
1466
+ // HTML table rendering for gg.table() entries
1467
+ if (entry.tableData && entry.tableData.keys.length > 0) {
1468
+ const { keys, rows: tableRows } = entry.tableData;
1469
+ const headerCells = keys
1470
+ .map((k) => `<th style="padding: 2px 8px; border: 1px solid #ccc; background: #f0f0f0; font-size: 11px; white-space: nowrap;">${escapeHtml(k)}</th>`)
1471
+ .join('');
1472
+ const bodyRowsHtml = tableRows
1473
+ .map((row) => {
1474
+ const cells = keys
1475
+ .map((k) => {
1476
+ const val = row[k];
1477
+ const display = val === undefined ? '' : escapeHtml(String(val));
1478
+ return `<td style="padding: 2px 8px; border: 1px solid #ddd; font-size: 11px; white-space: nowrap;">${display}</td>`;
1479
+ })
1480
+ .join('');
1481
+ return `<tr>${cells}</tr>`;
1482
+ })
1483
+ .join('');
1484
+ argsHTML = `<table style="border-collapse: collapse; margin: 2px 0; font-family: monospace;"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRowsHtml}</tbody></table>`;
1485
+ }
1486
+ else if (entry.args.length > 0) {
1234
1487
  argsHTML = entry.args
1235
1488
  .map((arg, argIdx) => {
1236
1489
  if (typeof arg === 'object' && arg !== null) {
@@ -1238,9 +1491,14 @@ export function createGgPlugin(options, gg) {
1238
1491
  const preview = Array.isArray(arg) ? `Array(${arg.length})` : 'Object';
1239
1492
  const jsonStr = escapeHtml(JSON.stringify(arg, null, 2));
1240
1493
  const uniqueId = `${index}-${argIdx}`;
1494
+ // Expression header inside expanded details
1495
+ const srcHeader = srcExpr ? `<div class="gg-details-src">${srcExpr}</div>` : '';
1241
1496
  // Store details separately to render after the row
1242
- 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>`;
1243
- return `<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}">${preview}</span>`;
1497
+ 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>`;
1498
+ // data-entry/data-arg for hover tooltip lookup, data-src for expression context
1499
+ const srcAttr = srcExpr ? ` data-src="${srcExpr}"` : '';
1500
+ const srcIcon = srcExpr ? `<span class="gg-src-icon">\uD83D\uDD0D</span>` : '';
1501
+ 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>`;
1244
1502
  }
1245
1503
  else {
1246
1504
  // Parse ANSI codes first, then convert URLs to clickable links
@@ -1281,20 +1539,41 @@ export function createGgPlugin(options, gg) {
1281
1539
  }
1282
1540
  }
1283
1541
  const fileTitle = fileTitleText ? ` title="${escapeHtml(fileTitleText)}"` : '';
1542
+ // Level class for warn/error styling
1543
+ const levelClass = entry.level === 'warn'
1544
+ ? ' gg-level-warn'
1545
+ : entry.level === 'error'
1546
+ ? ' gg-level-error'
1547
+ : '';
1548
+ // Stack trace toggle (for error/trace entries with captured stacks)
1549
+ let stackHTML = '';
1550
+ if (entry.stack) {
1551
+ const stackId = `stack-${index}`;
1552
+ stackHTML =
1553
+ `<span class="gg-stack-toggle" data-stack-id="${stackId}">▶ stack</span>` +
1554
+ `<div class="gg-stack-content" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
1555
+ }
1284
1556
  // Desktop: grid layout, Mobile: stacked layout
1285
- return (`<div class="gg-log-entry">` +
1557
+ return (`<div class="gg-log-entry${levelClass}">` +
1286
1558
  `<div class="gg-log-header">` +
1287
1559
  iconsCol +
1288
1560
  `<div class="gg-log-diff${soloClass}" style="color: ${color};"${soloAttr}>${diff}</div>` +
1289
1561
  `<div class="gg-log-ns${soloClass}" style="color: ${color};"${soloAttr}${fileAttr}${lineAttr}${colAttr}${fileTitle}>${ns}</div>` +
1290
1562
  `<div class="gg-log-handle"></div>` +
1291
1563
  `</div>` +
1292
- `<div class="gg-log-content"${entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}</div>` +
1564
+ `<div class="gg-log-content"${!entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src) ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${stackHTML}</div>` +
1293
1565
  detailsHTML +
1294
1566
  `</div>`);
1295
1567
  })
1296
1568
  .join('')}</div>`;
1297
1569
  logContainer.html(logsHTML);
1570
+ // Append hover tooltip div (destroyed by .html() each render, so re-create)
1571
+ const containerDom = logContainer.get(0);
1572
+ if (containerDom) {
1573
+ const tip = document.createElement('div');
1574
+ tip.className = 'gg-hover-tooltip';
1575
+ containerDom.appendChild(tip);
1576
+ }
1298
1577
  // Re-wire expanders after rendering
1299
1578
  wireUpExpanders();
1300
1579
  // Auto-scroll to bottom
@@ -1326,6 +1605,7 @@ export function createGgPlugin(options, gg) {
1326
1605
  */
1327
1606
  function stripAnsi(text) {
1328
1607
  // Remove all ANSI escape codes
1608
+ // eslint-disable-next-line no-control-regex
1329
1609
  return text.replace(/\x1b\[[0-9;]*m/g, '');
1330
1610
  }
1331
1611
  // Standard ANSI 3/4-bit color palette
@@ -1376,6 +1656,7 @@ export function createGgPlugin(options, gg) {
1376
1656
  */
1377
1657
  function parseAnsiToHtml(text) {
1378
1658
  // ANSI escape sequence regex
1659
+ // eslint-disable-next-line no-control-regex
1379
1660
  const ansiRegex = /\x1b\[([0-9;]+)m/g;
1380
1661
  let html = '';
1381
1662
  let lastIndex = 0;