@leftium/gg 0.0.50 → 0.0.51

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,6 +1,7 @@
1
1
  import { DEV } from 'esm-env';
2
2
  import { LogBuffer } from './buffer.js';
3
3
  import { Virtualizer, elementScroll, observeElementOffset, observeElementRect, measureElement } from '@tanstack/virtual-core';
4
+ import { matchesGlob, matchesPattern as _matchesPattern } from '../pattern.js';
4
5
  const _ggCallSitesPlugin = typeof __GG_TAG_PLUGIN__ !== 'undefined' ? __GG_TAG_PLUGIN__ : false;
5
6
  /**
6
7
  * Creates the gg Eruda plugin
@@ -9,7 +10,10 @@ const _ggCallSitesPlugin = typeof __GG_TAG_PLUGIN__ !== 'undefined' ? __GG_TAG_P
9
10
  * Methods: $el.html(), $el.show(), $el.hide(), $el.find(), $el.on()
10
11
  */
11
12
  export function createGgPlugin(options, gg) {
12
- const buffer = new LogBuffer(options.maxEntries ?? 2000);
13
+ const _savedCap = typeof localStorage !== 'undefined'
14
+ ? parseInt(localStorage.getItem('gg-buffer-cap') ?? '', 10)
15
+ : NaN;
16
+ const buffer = new LogBuffer(!isNaN(_savedCap) && _savedCap > 0 ? _savedCap : (options.maxEntries ?? 2000));
13
17
  // The licia jQuery-like wrapper Eruda passes to init()
14
18
  let $el = null;
15
19
  let expanderAttached = false;
@@ -17,7 +21,6 @@ export function createGgPlugin(options, gg) {
17
21
  // null = auto (fit content), number = user-dragged px width
18
22
  let nsColWidth = null;
19
23
  // Filter UI state
20
- let filterExpanded = false;
21
24
  let filterPattern = '';
22
25
  const enabledNamespaces = new Set();
23
26
  // Virtual scroll: filtered indices into the buffer (the "rows" the virtualizer sees)
@@ -29,16 +32,36 @@ export function createGgPlugin(options, gg) {
29
32
  // stack IDs (e.g. "stack-42") for stack traces.
30
33
  const expandedDetails = new Set();
31
34
  const expandedStacks = new Set();
32
- // Toast state for "namespace hidden" feedback
35
+ // Toast state
33
36
  let lastHiddenPattern = null; // filterPattern before the hide (for undo)
37
+ let lastDroppedPattern = null; // keepPattern before the drop (for undo)
38
+ let lastKeptPattern = null; // keepPattern before the [+] keep (for undo)
39
+ let lastKeptNamespaceInfo = null; // sentinel entry to restore on undo
40
+ let toastMode = 'hide'; // which layer the current toast targets
34
41
  let hasSeenToastExplanation = false; // first toast auto-expands help text
42
+ // Sentinel section debounce: pending rAF for re-rendering dropped sentinels
43
+ let sentinelRenderPending = false;
44
+ // Whether the sentinel section is expanded (persists across re-renders)
45
+ let sentinelExpanded = true;
35
46
  // Settings UI state
36
47
  let settingsExpanded = false;
48
+ // Layer 1: Keep gate pattern ('gg-keep') — controls which loggs enter the ring buffer.
49
+ // Default 'gg:*' (keep all gg namespaces). Users narrow it to reduce buffer pressure.
50
+ let keepPattern = 'gg:*';
51
+ // Native console output toggle ('gg-console')
52
+ // Default: true (gg works without Eruda), but Eruda flips it to false on init
53
+ // unless the user has explicitly set it.
54
+ let ggConsoleEnabled = true;
37
55
  // Expression visibility toggle
38
56
  let showExpressions = false;
39
- // Filter pattern persistence key (independent of localStorage.debug)
40
- const FILTER_KEY = 'gg-filter';
57
+ // Filter pattern persistence keys
58
+ const SHOW_KEY = 'gg-show'; // Layer 2: which kept loggs to display in panel + console
59
+ const KEEP_KEY = 'gg-keep'; // Layer 1: which loggs enter the ring buffer
60
+ const CONSOLE_KEY = 'gg-console'; // Whether shown loggs also go to native console
41
61
  const SHOW_EXPRESSIONS_KEY = 'gg-show-expressions';
62
+ const BUFFER_CAP_KEY = 'gg-buffer-cap'; // Ring buffer capacity (maxEntries)
63
+ // Backward-compat alias (old key name — ignored after migration)
64
+ const LEGACY_FILTER_KEY = 'gg-filter';
42
65
  // Namespace click action: 'open' uses Vite dev middleware, 'copy' copies formatted string, 'open-url' navigates to URI
43
66
  const NS_ACTION_KEY = 'gg-ns-action';
44
67
  const EDITOR_BIN_KEY = 'gg-editor-bin';
@@ -50,6 +73,11 @@ export function createGgPlugin(options, gg) {
50
73
  let pendingEntries = []; // new entries since last render
51
74
  // All namespaces ever seen (maintained incrementally, avoids scanning buffer)
52
75
  const allNamespacesSet = new Set();
76
+ // Phase 2: loggs dropped by the keep gate, tracked outside the ring buffer.
77
+ // Key: namespace string. Grows with distinct dropped namespaces (expected: tens, not thousands).
78
+ const droppedNamespaces = new Map();
79
+ // Total loggs ever received (kept + dropped).
80
+ let receivedTotal = 0;
53
81
  // Plugin detection state (probed once at init)
54
82
  let openInEditorPluginDetected = null; // null = not yet probed
55
83
  // Editor bins for launch-editor (common first, then alphabetical)
@@ -126,8 +154,7 @@ export function createGgPlugin(options, gg) {
126
154
  function formatEntryForClipboard(entry, includeExpressions) {
127
155
  // Extract HH:MM:SS.mmm from timestamp (with milliseconds)
128
156
  const time = new Date(entry.timestamp).toISOString().slice(11, 23);
129
- // Trim namespace and strip 'gg:' prefix to save tokens
130
- const ns = entry.namespace.trim().replace(/^gg:/, '');
157
+ const ns = entry.namespace.trim();
131
158
  // Include expression on its own line above the value when toggle is enabled
132
159
  const hasSrcExpr = !entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src);
133
160
  const exprLine = includeExpressions && hasSrcExpr ? `\u2039${entry.src}\u203A\n` : '';
@@ -147,18 +174,77 @@ export function createGgPlugin(options, gg) {
147
174
  name: 'GG',
148
175
  init($container) {
149
176
  $el = $container;
150
- // Load filter state BEFORE registering _onLog hook, because setting _onLog
177
+ // Load filter state BEFORE registering _onLog hook, because registering
151
178
  // triggers replay of earlyLogBuffer and each entry checks filterPattern
152
- filterPattern = localStorage.getItem(FILTER_KEY) || 'gg:*';
179
+ const _legacyFilter = localStorage.getItem(LEGACY_FILTER_KEY);
180
+ filterPattern = localStorage.getItem(SHOW_KEY) || _legacyFilter || 'gg:*';
181
+ keepPattern = localStorage.getItem(KEEP_KEY) || 'gg:*';
153
182
  showExpressions = localStorage.getItem(SHOW_EXPRESSIONS_KEY) === 'true';
154
- // Register the capture hook on gg
183
+ // gg-console: Eruda flips to false on init, unless user explicitly set it.
184
+ // This lets gg work zero-config (console output enabled) before Eruda loads,
185
+ // while silencing the noise when the Eruda panel is in use.
186
+ const userSetConsole = localStorage.getItem(CONSOLE_KEY);
187
+ if (userSetConsole === null) {
188
+ // Not explicitly set — Eruda auto-flips to false
189
+ localStorage.setItem(CONSOLE_KEY, 'false');
190
+ ggConsoleEnabled = false;
191
+ }
192
+ else {
193
+ ggConsoleEnabled = userSetConsole !== 'false';
194
+ }
195
+ // Tell the debug factory to reload its enabled state from gg-show/gg-console.
196
+ // browser.ts load() now reads gg-console + gg-show instead of localStorage.debug.
197
+ // Re-calling enable() with the right pattern updates which namespaces output to console.
198
+ import('../debug/index.js').then(({ default: dbg }) => {
199
+ try {
200
+ // Only call enable() when console output is on — enable('') would
201
+ // call localStorage.removeItem('gg-show'), wiping the persisted Show filter.
202
+ // When console is disabled, load() in browser.ts already returns '' via
203
+ // the gg-console=false check, so no enable() call is needed.
204
+ if (ggConsoleEnabled) {
205
+ dbg.enable(filterPattern || 'gg:*');
206
+ }
207
+ }
208
+ catch {
209
+ // ignore
210
+ }
211
+ });
212
+ // Register the capture hook on gg (prefer multi-listener API, fall back to legacy)
155
213
  if (gg) {
156
- gg._onLog = (entry) => {
214
+ const onEntry = (entry) => {
215
+ // Track total received (before any filtering)
216
+ receivedTotal++;
217
+ // Layer 1: Keep gate — drop loggs that don't match gg-keep
218
+ const effectiveKeep = keepPattern || 'gg:*';
219
+ if (!namespaceMatchesPattern(entry.namespace, effectiveKeep)) {
220
+ // Logg is dropped — not stored in ring buffer. Track it in droppedNamespaces.
221
+ const typeKey = entry.level ?? 'log';
222
+ const existing = droppedNamespaces.get(entry.namespace);
223
+ if (existing) {
224
+ existing.lastSeen = entry.timestamp;
225
+ existing.total++;
226
+ existing.byType[typeKey] = (existing.byType[typeKey] ?? 0) + 1;
227
+ existing.preview = entry;
228
+ }
229
+ else {
230
+ droppedNamespaces.set(entry.namespace, {
231
+ namespace: entry.namespace,
232
+ firstSeen: entry.timestamp,
233
+ lastSeen: entry.timestamp,
234
+ total: 1,
235
+ byType: { [typeKey]: 1 },
236
+ preview: entry
237
+ });
238
+ }
239
+ // Schedule debounced sentinel re-render
240
+ scheduleSentinelRender();
241
+ return;
242
+ }
157
243
  // Track namespaces incrementally (O(1) instead of scanning buffer)
158
244
  const isNewNamespace = !allNamespacesSet.has(entry.namespace);
159
245
  allNamespacesSet.add(entry.namespace);
160
246
  buffer.push(entry);
161
- // Add new namespace to enabledNamespaces if it matches the current pattern
247
+ // Layer 2: Show filter track which namespaces are currently visible
162
248
  const effectivePattern = filterPattern || 'gg:*';
163
249
  if (namespaceMatchesPattern(entry.namespace, effectivePattern)) {
164
250
  enabledNamespaces.add(entry.namespace);
@@ -179,6 +265,15 @@ export function createGgPlugin(options, gg) {
179
265
  });
180
266
  }
181
267
  };
268
+ if (gg.addLogListener) {
269
+ gg.addLogListener(onEntry);
270
+ // Store reference for removal on destroy
271
+ gg.__ggErudaListener = onEntry;
272
+ }
273
+ else {
274
+ // Legacy fallback: single-slot _onLog
275
+ gg._onLog = onEntry;
276
+ }
182
277
  }
183
278
  // Probe for openInEditorPlugin (status 222) and auto-populate $ROOT in dev mode
184
279
  if (DEV) {
@@ -187,7 +282,7 @@ export function createGgPlugin(options, gg) {
187
282
  openInEditorPluginDetected = r.status === 222;
188
283
  // If plugin detected, fetch project root for $ROOT variable
189
284
  if (openInEditorPluginDetected && !projectRoot) {
190
- return fetch('/__gg-project-root').then((r) => r.text());
285
+ return fetch('/__gg/project-root').then((r) => r.text());
191
286
  }
192
287
  })
193
288
  .then((root) => {
@@ -209,6 +304,7 @@ export function createGgPlugin(options, gg) {
209
304
  wireUpExpanders();
210
305
  wireUpResize();
211
306
  wireUpFilterUI();
307
+ wireUpKeepUI();
212
308
  wireUpSettingsUI();
213
309
  wireUpToast();
214
310
  // Discard any entries queued during early-buffer replay (before the DOM
@@ -231,13 +327,22 @@ export function createGgPlugin(options, gg) {
231
327
  },
232
328
  destroy() {
233
329
  if (gg) {
234
- gg._onLog = null;
330
+ const listener = gg
331
+ .__ggErudaListener;
332
+ if (gg.removeLogListener && listener) {
333
+ gg.removeLogListener(listener);
334
+ delete gg.__ggErudaListener;
335
+ }
336
+ else {
337
+ gg._onLog = null;
338
+ }
235
339
  }
236
340
  // Clean up virtualizer
237
341
  if (virtualizer && $el) {
238
342
  const containerDom = $el.find('.gg-log-container').get(0);
239
343
  if (containerDom) {
240
- const cleanup = containerDom.__ggVirtualCleanup;
344
+ const cleanup = containerDom
345
+ .__ggVirtualCleanup;
241
346
  if (cleanup)
242
347
  cleanup();
243
348
  }
@@ -245,9 +350,39 @@ export function createGgPlugin(options, gg) {
245
350
  }
246
351
  buffer.clear();
247
352
  allNamespacesSet.clear();
353
+ droppedNamespaces.clear();
248
354
  filteredIndices = [];
355
+ receivedTotal = 0;
356
+ },
357
+ /** Returns a read-only view of the dropped-namespace tracking map (Phase 2 data layer). */
358
+ getDroppedNamespaces() {
359
+ return droppedNamespaces;
249
360
  }
250
361
  };
362
+ function toggleKeepNamespace(namespace, enable) {
363
+ const currentPattern = keepPattern || 'gg:*';
364
+ const ns = namespace.trim();
365
+ const parts = currentPattern
366
+ .split(',')
367
+ .map((p) => p.trim())
368
+ .filter(Boolean);
369
+ if (enable) {
370
+ // Remove exclusion — namespace is now kept
371
+ const filtered = parts.filter((p) => p !== `-${ns}`);
372
+ keepPattern = filtered.join(',') || 'gg:*';
373
+ // Remove from droppedNamespaces so sentinel disappears
374
+ droppedNamespaces.delete(ns);
375
+ }
376
+ else {
377
+ // Add exclusion — namespace is dropped
378
+ const exclusion = `-${ns}`;
379
+ if (!parts.includes(exclusion))
380
+ parts.push(exclusion);
381
+ keepPattern = parts.join(',');
382
+ }
383
+ keepPattern = simplifyPattern(keepPattern) || 'gg:*';
384
+ localStorage.setItem(KEEP_KEY, keepPattern);
385
+ }
251
386
  function toggleNamespace(namespace, enable) {
252
387
  const currentPattern = filterPattern || 'gg:*';
253
388
  const ns = namespace.trim();
@@ -310,21 +445,38 @@ export function createGgPlugin(options, gg) {
310
445
  enabledNamespaces.add(ns);
311
446
  }
312
447
  });
313
- // Persist the new pattern
314
- localStorage.setItem(FILTER_KEY, filterPattern);
448
+ localStorage.setItem(SHOW_KEY, filterPattern);
315
449
  }
316
450
  function simplifyPattern(pattern) {
317
451
  if (!pattern)
318
452
  return '';
319
- // Remove empty parts
320
- let parts = pattern
453
+ // Remove empty parts and duplicates
454
+ const parts = Array.from(new Set(pattern
321
455
  .split(',')
322
456
  .map((p) => p.trim())
323
- .filter(Boolean);
324
- // Remove duplicates
325
- parts = Array.from(new Set(parts));
326
- // Clean up trailing/leading commas
327
- return parts.join(',');
457
+ .filter(Boolean)));
458
+ const inclusions = parts.filter((p) => !p.startsWith('-'));
459
+ const exclusions = parts.filter((p) => p.startsWith('-'));
460
+ const hasWildcardBase = inclusions.includes('gg:*') || inclusions.includes('*');
461
+ const wildcardBase = inclusions.includes('gg:*') ? 'gg:*' : '*';
462
+ // If there's a wildcard base (gg:* or *), drop all other inclusions (they're subsumed)
463
+ const finalInclusions = hasWildcardBase ? [wildcardBase] : inclusions;
464
+ // Drop exclusions that are subsumed by a broader exclusion.
465
+ // e.g. -routes/demo-helpers.ts:validation is subsumed by -routes/demo-*
466
+ const finalExclusions = exclusions.filter((excl) => {
467
+ const exclNs = excl.slice(1); // strip leading '-'
468
+ // Keep this exclusion only if no other exclusion is broader and covers it
469
+ return !exclusions.some((other) => {
470
+ if (other === excl)
471
+ return false;
472
+ const otherNs = other.slice(1);
473
+ return matchesGlob(exclNs, otherNs);
474
+ });
475
+ });
476
+ // If there's a wildcard base, also drop inclusions that match no exclusion boundary
477
+ // (they can't add any namespace that * doesn't already include)
478
+ // Final inclusions are already collapsed to ['*'] above, so nothing more to do.
479
+ return [...finalInclusions, ...finalExclusions].join(',');
328
480
  }
329
481
  function getAllCapturedNamespaces() {
330
482
  return Array.from(allNamespacesSet).sort();
@@ -332,54 +484,20 @@ export function createGgPlugin(options, gg) {
332
484
  function namespaceMatchesPattern(namespace, pattern) {
333
485
  if (!pattern)
334
486
  return true; // Empty pattern = show all
335
- // Split by comma for OR logic
336
- const parts = pattern.split(',').map((p) => p.trim());
337
- let included = false;
338
- let excluded = false;
339
- for (const part of parts) {
340
- if (part.startsWith('-')) {
341
- // Exclusion pattern
342
- const excludePattern = part.slice(1);
343
- if (matchesGlob(namespace, excludePattern)) {
344
- excluded = true;
345
- }
346
- }
347
- else {
348
- // Inclusion pattern
349
- if (matchesGlob(namespace, part)) {
350
- included = true;
351
- }
352
- }
353
- }
354
- // If no inclusion patterns, default to included
355
- const hasInclusions = parts.some((p) => !p.startsWith('-'));
356
- if (!hasInclusions)
357
- included = true;
358
- return included && !excluded;
359
- }
360
- function matchesGlob(str, pattern) {
361
- // Trim both for comparison (namespaces may have trailing spaces from padEnd)
362
- const s = str.trim();
363
- const p = pattern.trim();
364
- // Convert glob pattern to regex
365
- const regexPattern = p
366
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars
367
- .replace(/\*/g, '.*'); // * becomes .*
368
- const regex = new RegExp(`^${regexPattern}$`);
369
- return regex.test(s);
487
+ return _matchesPattern(namespace, pattern);
370
488
  }
371
489
  function isSimplePattern(pattern) {
372
490
  if (!pattern)
373
491
  return true;
374
492
  // Simple patterns:
375
- // 1. 'gg:*' with optional exclusions
376
- // 2. Explicit comma-separated list of exact namespaces
493
+ // 1. '*' with optional exclusions (e.g. '*,-api:verbose:*')
494
+ // 2. Explicit comma-separated list of exact namespaces (no wildcards, no exclusions)
377
495
  const parts = pattern.split(',').map((p) => p.trim());
378
- // Check if it's 'gg:*' based (with exclusions)
379
- const hasWildcardBase = parts.some((p) => p === 'gg:*' || p === '*');
496
+ // Check if it's '*' or 'gg:*' based (with exclusions)
497
+ const hasWildcardBase = parts.some((p) => p === '*' || p === 'gg:*');
380
498
  if (hasWildcardBase) {
381
- // All other parts must be exclusions starting with '-gg:'
382
- const otherParts = parts.filter((p) => p !== 'gg:*' && p !== '*');
499
+ // All other parts must be plain exclusions (no wildcards in the exclusion)
500
+ const otherParts = parts.filter((p) => p !== '*' && p !== 'gg:*');
383
501
  return otherParts.every((p) => p.startsWith('-') && !p.includes('*', 1));
384
502
  }
385
503
  // Check if it's an explicit list (no wildcards)
@@ -393,6 +511,30 @@ export function createGgPlugin(options, gg) {
393
511
  // at a time so auto would resize based on visible subset.
394
512
  return `3.5em ${ns} 4px 1fr`;
395
513
  }
514
+ // ─── Inline SVG icons ────────────────────────────────────────────────────
515
+ // All icons use currentColor so they inherit the namespace color on log rows
516
+ // or the green tint on sentinel keep buttons.
517
+ const SVG_ATTR = `viewBox="0 0 12 12" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"`;
518
+ /** Eye with diagonal slash — hide from view (Layer 2 / gg-show) */
519
+ const ICON_HIDE = `<svg ${SVG_ATTR}>` +
520
+ `<path d="M1 6 C2.5 2.5 9.5 2.5 11 6 C9.5 9.5 2.5 9.5 1 6"/>` +
521
+ `<circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none"/>` +
522
+ `<line x1="9.5" y1="1.5" x2="2.5" y2="10.5"/>` +
523
+ `</svg>`;
524
+ /** Trash can — drop from buffer (Layer 1 / gg-keep) */
525
+ const ICON_DROP = `<svg ${SVG_ATTR}>` +
526
+ `<line x1="2" y1="3.5" x2="10" y2="3.5"/>` +
527
+ `<path d="M4.5 3.5V2.5h3v1"/>` +
528
+ `<path d="M3 3.5l.5 7h5l.5-7"/>` +
529
+ `<line x1="5" y1="5.5" x2="5" y2="9"/>` +
530
+ `<line x1="7" y1="5.5" x2="7" y2="9"/>` +
531
+ `</svg>`;
532
+ /** Plus inside a circle — keep in buffer (Layer 1 / gg-keep) */
533
+ const ICON_KEEP = `<svg ${SVG_ATTR}>` +
534
+ `<circle cx="6" cy="6" r="4.5"/>` +
535
+ `<line x1="6" y1="3.5" x2="6" y2="8.5"/>` +
536
+ `<line x1="3.5" y1="6" x2="8.5" y2="6"/>` +
537
+ `</svg>`;
396
538
  function buildHTML() {
397
539
  return `
398
540
  <style>
@@ -458,6 +600,7 @@ export function createGgPlugin(options, gg) {
458
600
  text-decoration-style: solid;
459
601
  text-underline-offset: 2px;
460
602
  }
603
+
461
604
  .gg-details {
462
605
  grid-column: 1 / -1;
463
606
  border-top: none;
@@ -485,25 +628,213 @@ export function createGgPlugin(options, gg) {
485
628
  text-overflow: ellipsis;
486
629
  min-width: 0;
487
630
  }
488
- .gg-ns-hide {
631
+ .gg-ns-hide,
632
+ .gg-ns-drop {
489
633
  all: unset;
490
634
  cursor: pointer;
491
635
  opacity: 0;
492
- font-size: 14px;
493
- font-weight: bold;
494
- line-height: 1;
495
- padding: 1px 4px;
496
- transition: opacity 0.15s;
636
+ display: inline-flex;
637
+ align-items: center;
638
+ padding: 2px 3px;
639
+ border-radius: 3px;
640
+ transition: opacity 0.15s, background 0.1s;
497
641
  flex-shrink: 0;
498
642
  }
499
- .gg-log-ns:hover .gg-ns-hide {
500
- opacity: 0.4;
643
+ .gg-ns-hide svg,
644
+ .gg-ns-drop svg,
645
+ .gg-sentinel-keep svg {
646
+ pointer-events: none;
647
+ }
648
+ .gg-log-ns:hover .gg-ns-hide,
649
+ .gg-log-ns:hover .gg-ns-drop {
650
+ opacity: 0.35;
501
651
  }
502
652
  .gg-ns-hide:hover {
503
653
  opacity: 1 !important;
504
654
  background: rgba(0,0,0,0.08);
505
- border-radius: 3px;
506
655
  }
656
+ .gg-ns-drop:hover {
657
+ opacity: 1 !important;
658
+ background: rgba(200,50,0,0.12);
659
+ }
660
+ /* Sentinel section: collapsible above log container */
661
+ .gg-sentinel-section {
662
+ flex-shrink: 0;
663
+ background: #f9f9f9;
664
+ border-bottom: 2px solid rgba(0,0,0,0.1);
665
+ }
666
+ .gg-sentinel-header {
667
+ display: flex;
668
+ align-items: center;
669
+ gap: 6px;
670
+ padding: 4px 10px;
671
+ cursor: pointer;
672
+ font-size: 11px;
673
+ opacity: 0.6;
674
+ user-select: none;
675
+ }
676
+ .gg-sentinel-header:hover {
677
+ opacity: 1;
678
+ background: rgba(0,0,0,0.03);
679
+ }
680
+ .gg-sentinel-toggle {
681
+ font-size: 10px;
682
+ }
683
+ .gg-sentinel-rows {
684
+ max-height: 120px;
685
+ overflow-y: auto;
686
+ }
687
+ .gg-sentinel-rows.collapsed {
688
+ display: none;
689
+ }
690
+ .gg-sentinel-row {
691
+ display: flex;
692
+ align-items: flex-start;
693
+ gap: 6px;
694
+ padding: 5px 10px 4px;
695
+ border-bottom: 1px solid rgba(0,0,0,0.04);
696
+ color: #888;
697
+ font-family: monospace;
698
+ font-size: 12px;
699
+ }
700
+ .gg-sentinel-row:last-child {
701
+ border-bottom: none;
702
+ }
703
+ .gg-sentinel-keep {
704
+ all: unset;
705
+ cursor: pointer;
706
+ color: #4caf50;
707
+ flex-shrink: 0;
708
+ display: inline-flex;
709
+ align-items: center;
710
+ padding: 2px 3px;
711
+ border-radius: 3px;
712
+ transition: background 0.1s;
713
+ }
714
+ .gg-sentinel-keep:hover {
715
+ background: rgba(76,175,80,0.15);
716
+ }
717
+ .gg-sentinel-ns {
718
+ font-weight: bold;
719
+ color: #777;
720
+ }
721
+ .gg-sentinel-count {
722
+ color: #999;
723
+ flex-shrink: 0;
724
+ white-space: nowrap;
725
+ }
726
+ .gg-sentinel-preview {
727
+ color: #aaa;
728
+ font-style: italic;
729
+ overflow: hidden;
730
+ text-overflow: ellipsis;
731
+ white-space: nowrap;
732
+ min-width: 0;
733
+ flex: 1;
734
+ }
735
+ /* Pipeline row */
736
+ .gg-pipeline {
737
+ display: flex;
738
+ flex-wrap: wrap;
739
+ align-items: center;
740
+ gap: 2px;
741
+ padding: 3px 2px;
742
+ flex-shrink: 0;
743
+ margin-bottom: 2px;
744
+ }
745
+ .gg-pipeline-arrow {
746
+ font-size: 11px;
747
+ opacity: 0.35;
748
+ flex-shrink: 0;
749
+ user-select: none;
750
+ }
751
+ .gg-pipeline-node {
752
+ font-size: 11px;
753
+ font-family: monospace;
754
+ background: rgba(0,0,0,0.06);
755
+ border-radius: 4px;
756
+ padding: 2px 6px;
757
+ white-space: nowrap;
758
+ color: #444;
759
+ border: none;
760
+ cursor: default;
761
+ }
762
+ button.gg-pipeline-node {
763
+ cursor: pointer;
764
+ }
765
+ button.gg-pipeline-node:hover {
766
+ background: rgba(0,0,0,0.11);
767
+ }
768
+ .gg-buf-size-input {
769
+ width: 5em;
770
+ font-family: monospace;
771
+ font-size: 11px;
772
+ padding: 1px 4px;
773
+ border: 1px solid rgba(0,0,0,0.25);
774
+ border-radius: 3px;
775
+ background: #fff;
776
+ }
777
+ .gg-pipeline-handle {
778
+ all: unset;
779
+ font-size: 10px;
780
+ font-family: monospace;
781
+ color: #888;
782
+ cursor: pointer;
783
+ padding: 1px 5px;
784
+ border-radius: 3px;
785
+ border: 1px solid rgba(0,0,0,0.15);
786
+ white-space: nowrap;
787
+ transition: background 0.1s, color 0.1s;
788
+ user-select: none;
789
+ }
790
+ .gg-pipeline-handle:hover,
791
+ .gg-pipeline-handle.active {
792
+ background: rgba(0,0,0,0.08);
793
+ color: #222;
794
+ }
795
+ .gg-pipeline-handle.active {
796
+ border-color: rgba(0,0,0,0.3);
797
+ }
798
+ /* Pipeline panels (keep / show) */
799
+ .gg-pipeline-panel {
800
+ flex-shrink: 0;
801
+ margin-bottom: 4px;
802
+ }
803
+ .gg-pipeline-panel-header {
804
+ display: flex;
805
+ align-items: center;
806
+ gap: 6px;
807
+ padding: 2px 2px 4px;
808
+ }
809
+ .gg-filter-label {
810
+ font-size: 11px;
811
+ opacity: 0.6;
812
+ white-space: nowrap;
813
+ flex-shrink: 0;
814
+ }
815
+ .gg-keep-input,
816
+ .gg-show-input {
817
+ flex: 1;
818
+ min-width: 0;
819
+ padding: 3px 6px;
820
+ font-family: monospace;
821
+ font-size: 13px;
822
+ border: 1px solid rgba(0,0,0,0.2);
823
+ border-radius: 3px;
824
+ background: transparent;
825
+ }
826
+ .gg-filter-count {
827
+ font-size: 11px;
828
+ opacity: 0.5;
829
+ white-space: nowrap;
830
+ flex-shrink: 0;
831
+ }
832
+ .gg-filter-details-body {
833
+ background: #f5f5f5;
834
+ padding: 8px 10px;
835
+ border-radius: 4px;
836
+ margin-bottom: 4px;
837
+ }
507
838
  /* Toast bar for "namespace hidden" feedback */
508
839
  .gg-toast {
509
840
  display: none;
@@ -796,25 +1127,7 @@ export function createGgPlugin(options, gg) {
796
1127
  .gg-stack-content.expanded {
797
1128
  display: block;
798
1129
  }
799
- .gg-filter-panel {
800
- background: #f5f5f5;
801
- padding: 10px;
802
- margin-bottom: 8px;
803
- border-radius: 4px;
804
- flex-shrink: 0;
805
- display: none;
806
- }
807
- .gg-filter-panel.expanded {
808
- display: block;
809
- }
810
- .gg-filter-pattern {
811
- width: 100%;
812
- padding: 4px 8px;
813
- font-family: monospace;
814
- font-size: 16px;
815
- margin-bottom: 8px;
816
- }
817
- .gg-filter-checkboxes {
1130
+ .gg-filter-checkboxes {
818
1131
  display: flex;
819
1132
  flex-wrap: wrap;
820
1133
  gap: 8px;
@@ -997,11 +1310,6 @@ export function createGgPlugin(options, gg) {
997
1310
  <span class="gg-btn-text">📋 <span class="gg-copy-count">Copy 0 entries</span></span>
998
1311
  <span class="gg-btn-icon" title="Copy">📋</span>
999
1312
  </button>
1000
- <button class="gg-filter-btn" style="text-align: left; white-space: nowrap;">
1001
- <span class="gg-btn-text">Namespaces: </span>
1002
- <span class="gg-btn-icon">NS: </span>
1003
- <span class="gg-filter-summary"></span>
1004
- </button>
1005
1313
  <button class="gg-expressions-btn" style="background: ${showExpressions ? '#e8f5e9' : 'transparent'};" title="Toggle expression visibility in logs and clipboard">
1006
1314
  <span class="gg-btn-text">\uD83D\uDD0D Expr</span>
1007
1315
  <span class="gg-btn-icon" title="Expressions">\uD83D\uDD0D</span>
@@ -1016,9 +1324,36 @@ export function createGgPlugin(options, gg) {
1016
1324
  <span class="gg-btn-icon" title="Clear">⊘</span>
1017
1325
  </button>
1018
1326
  </div>
1019
- <div class="gg-filter-panel"></div>
1327
+ <div class="gg-pipeline">
1328
+ <span class="gg-pipeline-node gg-pipeline-recv" title="Total loggs received by gg"></span>
1329
+ <span class="gg-pipeline-arrow">→</span>
1330
+ <button class="gg-pipeline-handle gg-pipeline-keep-handle" title="Edit keep filter (Layer 1: ring buffer gate)">keep</button>
1331
+ <span class="gg-pipeline-arrow">→</span>
1332
+ <button class="gg-pipeline-node gg-pipeline-buf" title="Click to change buffer size"></button>
1333
+ <span class="gg-pipeline-arrow">→</span>
1334
+ <button class="gg-pipeline-handle gg-pipeline-show-handle" title="Edit show filter (Layer 2: display filter)">show</button>
1335
+ <span class="gg-pipeline-arrow">→</span>
1336
+ <span class="gg-pipeline-node gg-pipeline-vis" title="Loggs currently visible"></span>
1337
+ </div>
1338
+ <div class="gg-pipeline-panel gg-keep-panel" style="display:none;">
1339
+ <div class="gg-pipeline-panel-header">
1340
+ <span class="gg-filter-label">Keep:</span>
1341
+ <input class="gg-keep-input" type="text" value="${escapeHtml(keepPattern)}" placeholder="gg:*" title="gg-keep: which loggs enter the ring buffer">
1342
+ <span class="gg-keep-filter-summary gg-filter-count"></span>
1343
+ </div>
1344
+ <div class="gg-keep-filter-panel gg-filter-details-body"></div>
1345
+ </div>
1346
+ <div class="gg-pipeline-panel gg-show-panel" style="display:none;">
1347
+ <div class="gg-pipeline-panel-header">
1348
+ <span class="gg-filter-label">Show:</span>
1349
+ <input class="gg-show-input" type="text" value="${escapeHtml(filterPattern)}" placeholder="gg:*" title="gg-show: which kept loggs to display">
1350
+ <span class="gg-filter-summary gg-filter-count"></span>
1351
+ </div>
1352
+ <div class="gg-filter-panel gg-filter-details-body"></div>
1353
+ </div>
1020
1354
  <div class="gg-settings-panel"></div>
1021
- <div class="gg-truncation-banner" style="display: none; padding: 6px 12px; background: #7f4f00; color: #ffe0a0; font-size: 11px; align-items: center; gap: 6px; flex-shrink: 0;"></div>
1355
+ <div class="gg-sentinel-section" style="display: none;"></div>
1356
+
1022
1357
  <div class="gg-log-container" style="flex: 1; overflow-y: auto; overflow-x: hidden; font-family: monospace; font-size: 12px; touch-action: pan-y; overscroll-behavior: contain;"></div>
1023
1358
  <div class="gg-toast"></div>
1024
1359
  <iframe class="gg-editor-iframe" hidden title="open-in-editor"></iframe>
@@ -1026,8 +1361,8 @@ export function createGgPlugin(options, gg) {
1026
1361
  `;
1027
1362
  }
1028
1363
  function applyPatternFromInput(value) {
1029
- filterPattern = value;
1030
- localStorage.setItem(FILTER_KEY, filterPattern);
1364
+ filterPattern = value || 'gg:*';
1365
+ localStorage.setItem(SHOW_KEY, filterPattern);
1031
1366
  // Sync enabledNamespaces from the new pattern
1032
1367
  const allNamespaces = getAllCapturedNamespaces();
1033
1368
  enabledNamespaces.clear();
@@ -1037,41 +1372,62 @@ export function createGgPlugin(options, gg) {
1037
1372
  enabledNamespaces.add(ns);
1038
1373
  }
1039
1374
  });
1375
+ // Sync toolbar Show input value
1376
+ if ($el) {
1377
+ const showInput = $el.find('.gg-show-input').get(0);
1378
+ if (showInput && document.activeElement !== showInput) {
1379
+ showInput.value = filterPattern;
1380
+ }
1381
+ }
1040
1382
  renderFilterUI();
1383
+ renderSentinelSection();
1041
1384
  renderLogs();
1042
1385
  }
1386
+ function applyKeepPatternFromInput(value) {
1387
+ keepPattern = value || 'gg:*';
1388
+ localStorage.setItem(KEEP_KEY, keepPattern);
1389
+ renderKeepUI();
1390
+ scheduleSentinelRender();
1391
+ }
1043
1392
  function wireUpFilterUI() {
1044
1393
  if (!$el)
1045
1394
  return;
1046
- const filterBtn = $el.find('.gg-filter-btn').get(0);
1047
1395
  const filterPanel = $el.find('.gg-filter-panel').get(0);
1048
- if (!filterBtn || !filterPanel)
1396
+ if (!filterPanel)
1049
1397
  return;
1050
1398
  renderFilterUI();
1051
- // Wire up button toggle (close settings if opening filter)
1052
- filterBtn.addEventListener('click', () => {
1053
- filterExpanded = !filterExpanded;
1054
- if (filterExpanded) {
1055
- settingsExpanded = false;
1056
- renderSettingsUI();
1057
- }
1058
- renderFilterUI();
1059
- renderLogs(); // Re-render to update grid columns
1060
- });
1061
- // Wire up pattern input - apply on blur or Enter
1062
- filterPanel.addEventListener('blur', (e) => {
1063
- const target = e.target;
1064
- if (target.classList.contains('gg-filter-pattern')) {
1065
- applyPatternFromInput(target.value);
1066
- }
1067
- }, true); // useCapture for blur (doesn't bubble)
1068
- filterPanel.addEventListener('keydown', (e) => {
1069
- const target = e.target;
1070
- if (target.classList.contains('gg-filter-pattern') && e.key === 'Enter') {
1071
- applyPatternFromInput(target.value);
1072
- target.blur();
1073
- }
1074
- });
1399
+ // Show handle toggles the show panel
1400
+ const showHandle = $el.find('.gg-pipeline-show-handle').get(0);
1401
+ const showPanel = $el.find('.gg-show-panel').get(0);
1402
+ if (showHandle && showPanel) {
1403
+ showHandle.addEventListener('click', () => {
1404
+ const open = showPanel.style.display !== 'none';
1405
+ showPanel.style.display = open ? 'none' : '';
1406
+ showHandle.classList.toggle('active', !open);
1407
+ // Close keep panel when opening show
1408
+ if (!open) {
1409
+ const keepPanel = $el?.find('.gg-keep-panel').get(0);
1410
+ const keepHandle = $el?.find('.gg-pipeline-keep-handle').get(0);
1411
+ if (keepPanel)
1412
+ keepPanel.style.display = 'none';
1413
+ if (keepHandle)
1414
+ keepHandle.classList.remove('active');
1415
+ }
1416
+ });
1417
+ }
1418
+ // Wire up the Show input (blur or Enter)
1419
+ const showInput = $el.find('.gg-show-input').get(0);
1420
+ if (showInput) {
1421
+ showInput.addEventListener('blur', () => {
1422
+ applyPatternFromInput(showInput.value);
1423
+ });
1424
+ showInput.addEventListener('keydown', (e) => {
1425
+ if (e.key === 'Enter') {
1426
+ applyPatternFromInput(showInput.value);
1427
+ showInput.blur();
1428
+ }
1429
+ });
1430
+ }
1075
1431
  // Wire up checkboxes
1076
1432
  filterPanel.addEventListener('change', (e) => {
1077
1433
  const target = e.target;
@@ -1090,8 +1446,9 @@ export function createGgPlugin(options, gg) {
1090
1446
  filterPattern = `gg:*,${exclusions}`;
1091
1447
  enabledNamespaces.clear();
1092
1448
  }
1093
- localStorage.setItem(FILTER_KEY, filterPattern);
1449
+ localStorage.setItem(SHOW_KEY, filterPattern);
1094
1450
  renderFilterUI();
1451
+ renderSentinelSection();
1095
1452
  renderLogs();
1096
1453
  return;
1097
1454
  }
@@ -1105,6 +1462,7 @@ export function createGgPlugin(options, gg) {
1105
1462
  toggleNamespaces(otherNamespaces, target.checked);
1106
1463
  // localStorage already saved in toggleNamespaces()
1107
1464
  renderFilterUI();
1465
+ renderSentinelSection();
1108
1466
  renderLogs();
1109
1467
  return;
1110
1468
  }
@@ -1117,96 +1475,426 @@ export function createGgPlugin(options, gg) {
1117
1475
  toggleNamespace(namespace, target.checked);
1118
1476
  // Re-render to update UI
1119
1477
  renderFilterUI();
1478
+ renderSentinelSection();
1120
1479
  renderLogs();
1121
1480
  }
1122
1481
  });
1123
1482
  }
1483
+ /** Update the three pipeline node labels with current counts. */
1484
+ function renderPipelineUI() {
1485
+ if (!$el)
1486
+ return;
1487
+ const keptNs = allNamespacesSet.size;
1488
+ const droppedNs = droppedNamespaces.size;
1489
+ const totalNs = keptNs + droppedNs;
1490
+ const visNs = enabledNamespaces.size;
1491
+ // recv node: "N total loggs" (no ns count — it moves to the keep button)
1492
+ const recvNode = $el.find('.gg-pipeline-recv').get(0);
1493
+ if (recvNode) {
1494
+ recvNode.textContent = `${receivedTotal} total loggs`;
1495
+ }
1496
+ // keep handle: "keep N/N namespaces" (kept ns / total ns ever seen)
1497
+ const keepHandle = $el.find('.gg-pipeline-keep-handle').get(0);
1498
+ if (keepHandle) {
1499
+ const countStr = totalNs ? ` ${keptNs}/${totalNs} namespaces` : '';
1500
+ // Preserve active class — only update text content
1501
+ keepHandle.textContent = `keep${countStr}`;
1502
+ }
1503
+ // buf node: buffer.size / buffer.capacity (no ns count — moved to keep button)
1504
+ const bufNode = $el.find('.gg-pipeline-buf').get(0);
1505
+ if (bufNode) {
1506
+ const bufSize = buffer.size;
1507
+ const bufCap = buffer.capacity;
1508
+ const full = bufSize >= bufCap;
1509
+ bufNode.textContent = full ? `${bufSize}/${bufCap} ⚠` : `${bufSize}/${bufCap}`;
1510
+ bufNode.style.color = full ? '#b94' : '';
1511
+ }
1512
+ // show handle: "show N/N namespaces" (visible ns / kept ns)
1513
+ const showHandle = $el.find('.gg-pipeline-show-handle').get(0);
1514
+ if (showHandle) {
1515
+ const countStr = keptNs ? ` ${visNs}/${keptNs} namespaces` : '';
1516
+ showHandle.textContent = `show${countStr}`;
1517
+ }
1518
+ // vis node: "N loggs shown" (no ns count — moved to show button)
1519
+ const visNode = $el.find('.gg-pipeline-vis').get(0);
1520
+ if (visNode) {
1521
+ visNode.textContent = `${filteredIndices.length} loggs shown`;
1522
+ }
1523
+ }
1124
1524
  function renderFilterUI() {
1125
1525
  if (!$el)
1126
1526
  return;
1527
+ renderPipelineUI();
1127
1528
  const allNamespaces = getAllCapturedNamespaces();
1128
1529
  const enabledCount = enabledNamespaces.size;
1129
1530
  const totalCount = allNamespaces.length;
1130
- // Update button summary with count of enabled namespaces
1531
+ // Sync input value (may have changed via hide/undo/right-click)
1532
+ const showInput = $el.find('.gg-show-input').get(0);
1533
+ if (showInput && document.activeElement !== showInput)
1534
+ showInput.value = filterPattern;
1535
+ // Update count in summary
1131
1536
  const filterSummary = $el.find('.gg-filter-summary').get(0);
1132
- if (filterSummary) {
1537
+ if (filterSummary)
1133
1538
  filterSummary.textContent = `${enabledCount}/${totalCount}`;
1134
- }
1135
- // Update panel
1539
+ // Always render panel body — <details> open state handles visibility
1136
1540
  const filterPanel = $el.find('.gg-filter-panel').get(0);
1137
1541
  if (!filterPanel)
1138
1542
  return;
1139
- if (filterExpanded) {
1140
- // Show panel
1141
- filterPanel.classList.add('expanded');
1142
- // Render expanded view
1143
- const allNamespaces = getAllCapturedNamespaces();
1144
- const simple = isSimplePattern(filterPattern);
1145
- const effectivePattern = filterPattern || 'gg:*';
1146
- let checkboxesHTML = '';
1147
- if (simple && allNamespaces.length > 0) {
1148
- const allChecked = enabledCount === totalCount;
1149
- // Count frequency of each namespace in the buffer
1150
- const allEntries = buffer.getEntries();
1151
- const nsCounts = new Map();
1152
- allEntries.forEach((entry) => {
1153
- nsCounts.set(entry.namespace, (nsCounts.get(entry.namespace) || 0) + 1);
1154
- });
1155
- // Sort ALL namespaces by frequency (most common first)
1156
- const sortedAllNamespaces = [...allNamespaces].sort((a, b) => (nsCounts.get(b) || 0) - (nsCounts.get(a) || 0));
1157
- // Take top 5 most common (regardless of enabled state)
1158
- const displayedNamespaces = sortedAllNamespaces.slice(0, 5);
1159
- // Calculate "other" namespaces (not in top 5)
1160
- const displayedSet = new Set(displayedNamespaces);
1161
- const otherNamespaces = allNamespaces.filter((ns) => !displayedSet.has(ns));
1162
- const otherEnabledCount = otherNamespaces.filter((ns) => enabledNamespaces.has(ns)).length;
1163
- const otherTotalCount = otherNamespaces.length;
1164
- const otherChecked = otherEnabledCount > 0;
1165
- const otherCount = otherNamespaces.reduce((sum, ns) => sum + (nsCounts.get(ns) || 0), 0);
1166
- checkboxesHTML = `
1167
- <div class="gg-filter-checkboxes">
1168
- <label class="gg-filter-checkbox" style="font-weight: bold;">
1169
- <input type="checkbox" class="gg-all-checkbox" ${allChecked ? 'checked' : ''}>
1170
- <span>ALL</span>
1171
- </label>
1172
- ${displayedNamespaces
1173
- .map((ns) => {
1174
- // Check if namespace matches the current pattern
1175
- const checked = namespaceMatchesPattern(ns, effectivePattern);
1176
- const count = nsCounts.get(ns) || 0;
1177
- return `
1178
- <label class="gg-filter-checkbox">
1179
- <input type="checkbox" class="gg-ns-checkbox" data-namespace="${escapeHtml(ns)}" ${checked ? 'checked' : ''}>
1180
- <span>${escapeHtml(ns)} (${count})</span>
1181
- </label>
1182
- `;
1183
- })
1184
- .join('')}
1185
- ${otherTotalCount > 0
1186
- ? `
1187
- <label class="gg-filter-checkbox" style="opacity: 0.7;">
1188
- <input type="checkbox" class="gg-other-checkbox" ${otherChecked ? 'checked' : ''} data-other-namespaces='${JSON.stringify(otherNamespaces)}'>
1189
- <span>other (${otherCount})</span>
1190
- </label>
1191
- `
1192
- : ''}
1193
- </div>
1194
- `;
1195
- }
1196
- else if (!simple) {
1197
- checkboxesHTML = `<div style="opacity: 0.6; font-size: 11px; margin: 8px 0;">⚠️ Complex pattern - edit manually (quick filters disabled)</div>`;
1198
- }
1199
- filterPanel.innerHTML = `
1200
- <div style="margin-bottom: 8px;">
1201
- <input type="text" class="gg-filter-pattern" value="${escapeHtml(filterPattern)}" placeholder="gg:*" style="width: 100%;">
1202
- </div>
1203
- ${checkboxesHTML}
1204
- `;
1543
+ const simple = isSimplePattern(filterPattern);
1544
+ const effectivePattern = filterPattern || 'gg:*';
1545
+ if (simple && allNamespaces.length > 0) {
1546
+ const allChecked = enabledCount === totalCount;
1547
+ const allEntries = buffer.getEntries();
1548
+ const nsCounts = new Map();
1549
+ allEntries.forEach((entry) => {
1550
+ nsCounts.set(entry.namespace, (nsCounts.get(entry.namespace) || 0) + 1);
1551
+ });
1552
+ const sortedAll = [...allNamespaces].sort((a, b) => (nsCounts.get(b) || 0) - (nsCounts.get(a) || 0));
1553
+ const displayed = sortedAll.slice(0, 5);
1554
+ const displayedSet = new Set(displayed);
1555
+ const others = allNamespaces.filter((ns) => !displayedSet.has(ns));
1556
+ const otherChecked = others.some((ns) => enabledNamespaces.has(ns));
1557
+ const otherCount = others.reduce((sum, ns) => sum + (nsCounts.get(ns) || 0), 0);
1558
+ filterPanel.innerHTML =
1559
+ `<div style="font-size: 11px; opacity: 0.6; margin-bottom: 6px;">Layer 2: controls which kept loggs are displayed.</div>` +
1560
+ `<div class="gg-filter-checkboxes">` +
1561
+ `<label class="gg-filter-checkbox" style="font-weight: bold;"><input type="checkbox" class="gg-all-checkbox" ${allChecked ? 'checked' : ''}><span>ALL</span></label>` +
1562
+ displayed
1563
+ .map((ns) => {
1564
+ const checked = namespaceMatchesPattern(ns, effectivePattern);
1565
+ return `<label class="gg-filter-checkbox"><input type="checkbox" class="gg-ns-checkbox" data-namespace="${escapeHtml(ns)}" ${checked ? 'checked' : ''}><span>${escapeHtml(ns)} (${nsCounts.get(ns) || 0})</span></label>`;
1566
+ })
1567
+ .join('') +
1568
+ (others.length > 0
1569
+ ? `<label class="gg-filter-checkbox" style="opacity: 0.7;"><input type="checkbox" class="gg-other-checkbox" ${otherChecked ? 'checked' : ''} data-other-namespaces='${JSON.stringify(others)}'><span>other (${otherCount})</span></label>`
1570
+ : '') +
1571
+ `</div>`;
1572
+ }
1573
+ else if (!simple) {
1574
+ filterPanel.innerHTML = `<div style="opacity: 0.6; font-size: 11px;">⚠️ Complex pattern — edit directly in the input above</div>`;
1205
1575
  }
1206
1576
  else {
1207
- // Hide panel
1208
- filterPanel.classList.remove('expanded');
1577
+ filterPanel.innerHTML = '';
1578
+ }
1579
+ }
1580
+ /** Render the Keep filter UI (count + panel body) */
1581
+ function renderKeepUI() {
1582
+ if (!$el)
1583
+ return;
1584
+ renderPipelineUI();
1585
+ const droppedCount = droppedNamespaces.size;
1586
+ const keptCount = allNamespacesSet.size;
1587
+ const totalCount = keptCount + droppedCount;
1588
+ // Sync input value
1589
+ const keepInput = $el.find('.gg-keep-input').get(0);
1590
+ if (keepInput && document.activeElement !== keepInput)
1591
+ keepInput.value = keepPattern;
1592
+ // Update count in summary (keep panel header)
1593
+ const keepSummary = $el.find('.gg-keep-filter-summary').get(0);
1594
+ if (keepSummary)
1595
+ keepSummary.textContent = `${keptCount}/${totalCount}`;
1596
+ // Always render panel body — <details> open state handles visibility
1597
+ const keepPanel = $el.find('.gg-keep-filter-panel').get(0);
1598
+ if (!keepPanel)
1599
+ return;
1600
+ const simple = isSimplePattern(keepPattern);
1601
+ const allKept = [...allNamespacesSet].sort();
1602
+ const allDropped = [...droppedNamespaces.keys()].sort();
1603
+ // Also extract namespaces explicitly excluded in keepPattern itself — these may
1604
+ // never have sent a logg so they won't be in allNamespacesSet or droppedNamespaces
1605
+ const patternExcluded = (keepPattern || 'gg:*')
1606
+ .split(',')
1607
+ .map((p) => p.trim())
1608
+ .filter((p) => p.startsWith('-') && p.length > 1)
1609
+ .map((p) => p.slice(1));
1610
+ const allNs = [...new Set([...allKept, ...allDropped, ...patternExcluded])];
1611
+ if (simple && allNs.length > 0) {
1612
+ const allChecked = droppedCount === 0;
1613
+ const effectiveKeep = keepPattern || 'gg:*';
1614
+ // Count loggs per namespace (kept + dropped combined for context)
1615
+ const allEntries = buffer.getEntries();
1616
+ const nsCounts = new Map();
1617
+ allEntries.forEach((entry) => {
1618
+ nsCounts.set(entry.namespace, (nsCounts.get(entry.namespace) || 0) + 1);
1619
+ });
1620
+ // Also add dropped counts
1621
+ droppedNamespaces.forEach((info, ns) => {
1622
+ nsCounts.set(ns, (nsCounts.get(ns) || 0) + info.total);
1623
+ });
1624
+ const sorted = allNs.sort((a, b) => (nsCounts.get(b) || 0) - (nsCounts.get(a) || 0));
1625
+ const displayed = sorted.slice(0, 5);
1626
+ const displayedSet = new Set(displayed);
1627
+ const others = allNs.filter((ns) => !displayedSet.has(ns));
1628
+ const otherKept = others.filter((ns) => namespaceMatchesPattern(ns, effectiveKeep));
1629
+ const otherCount = others.reduce((sum, ns) => sum + (nsCounts.get(ns) || 0), 0);
1630
+ keepPanel.innerHTML =
1631
+ `<div style="font-size: 11px; opacity: 0.6; margin-bottom: 6px;">Layer 1: controls which loggs enter the ring buffer.</div>` +
1632
+ `<div class="gg-filter-checkboxes">` +
1633
+ `<label class="gg-filter-checkbox" style="font-weight: bold;"><input type="checkbox" class="gg-keep-all-checkbox" ${allChecked ? 'checked' : ''}><span>ALL</span></label>` +
1634
+ displayed
1635
+ .map((ns) => {
1636
+ const checked = namespaceMatchesPattern(ns, effectiveKeep);
1637
+ return `<label class="gg-filter-checkbox"><input type="checkbox" class="gg-keep-ns-checkbox" data-namespace="${escapeHtml(ns)}" ${checked ? 'checked' : ''}><span>${escapeHtml(ns)} (${nsCounts.get(ns) || 0})</span></label>`;
1638
+ })
1639
+ .join('') +
1640
+ (others.length > 0
1641
+ ? `<label class="gg-filter-checkbox" style="opacity: 0.7;"><input type="checkbox" class="gg-keep-other-checkbox" ${otherKept.length > 0 ? 'checked' : ''} data-other-namespaces='${JSON.stringify(others)}'><span>other (${otherCount})</span></label>`
1642
+ : '') +
1643
+ `</div>`;
1644
+ }
1645
+ else if (!simple) {
1646
+ keepPanel.innerHTML = `<div style="opacity: 0.6; font-size: 11px;">⚠️ Complex pattern — edit directly in the input above</div>`;
1647
+ }
1648
+ else {
1649
+ keepPanel.innerHTML = '';
1650
+ }
1651
+ }
1652
+ /** Schedule a debounced re-render of the sentinel section (via rAF) */
1653
+ function scheduleSentinelRender() {
1654
+ if (sentinelRenderPending)
1655
+ return;
1656
+ sentinelRenderPending = true;
1657
+ requestAnimationFrame(() => {
1658
+ sentinelRenderPending = false;
1659
+ renderSentinelSection();
1660
+ });
1661
+ }
1662
+ /** Render the dropped-namespace sentinel section above the log container */
1663
+ function renderSentinelSection() {
1664
+ if (!$el)
1665
+ return;
1666
+ const sentinelSection = $el.find('.gg-sentinel-section').get(0);
1667
+ if (!sentinelSection)
1668
+ return;
1669
+ if (droppedNamespaces.size === 0) {
1670
+ sentinelSection.style.display = 'none';
1671
+ sentinelSection.innerHTML = '';
1672
+ return;
1673
+ }
1674
+ // Sort by total dropped count descending (noisiest first)
1675
+ // Also respect filterPattern (Layer 2 show filter) — sentinels for hidden namespaces
1676
+ // are themselves hidden, consistent with how filtered log entries are hidden.
1677
+ const effectiveShowPattern = filterPattern || 'gg:*';
1678
+ const sorted = [...droppedNamespaces.values()]
1679
+ .filter((info) => namespaceMatchesPattern(info.namespace, effectiveShowPattern))
1680
+ .sort((a, b) => b.total - a.total);
1681
+ if (sorted.length === 0) {
1682
+ sentinelSection.style.display = 'none';
1683
+ sentinelSection.innerHTML = '';
1684
+ return;
1685
+ }
1686
+ const total = sorted.reduce((sum, i) => sum + i.total, 0);
1687
+ // Header: "▼ Dropped: 3 namespaces, 47 loggs" — click to collapse
1688
+ const arrow = sentinelExpanded ? '▼' : '▶';
1689
+ const headerText = `${sorted.length} dropped namespace${sorted.length === 1 ? '' : 's'}, ${total} logg${total === 1 ? '' : 's'}`;
1690
+ let html = `<div class="gg-sentinel-header"><span class="gg-sentinel-toggle">${arrow}</span> ${escapeHtml(headerText)}</div>`;
1691
+ html += `<div class="gg-sentinel-rows${sentinelExpanded ? '' : ' collapsed'}">`;
1692
+ for (const info of sorted) {
1693
+ const ns = escapeHtml(info.namespace);
1694
+ const typeEntries = Object.entries(info.byType);
1695
+ const breakdown = typeEntries.length > 1 ? ` (${typeEntries.map(([t, n]) => `${n} ${t}`).join(', ')})` : '';
1696
+ const countStr = `${info.total} logg${info.total === 1 ? '' : 's'}${breakdown}`;
1697
+ let previewStr = '';
1698
+ if (info.preview.args && info.preview.args.length > 0) {
1699
+ const raw = info.preview.args
1700
+ .map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a)))
1701
+ .join(' ');
1702
+ previewStr = raw.length > 80 ? raw.slice(0, 77) + '...' : raw;
1703
+ }
1704
+ html +=
1705
+ `<div class="gg-sentinel-row" data-namespace="${ns}">` +
1706
+ `<button class="gg-sentinel-keep" data-namespace="${ns}" title="Keep this namespace (start capturing its loggs)">${ICON_KEEP}</button>` +
1707
+ `<span class="gg-sentinel-ns">DROPPED:${ns}</span>` +
1708
+ `<span class="gg-sentinel-count">${escapeHtml(countStr)}</span>` +
1709
+ (previewStr
1710
+ ? `<span class="gg-sentinel-preview">\u21b3 ${escapeHtml(previewStr)}</span>`
1711
+ : '') +
1712
+ `</div>`;
1209
1713
  }
1714
+ html += `</div>`;
1715
+ sentinelSection.innerHTML = html;
1716
+ sentinelSection.style.display = 'block';
1717
+ // Update keep UI summary too
1718
+ renderKeepUI();
1719
+ }
1720
+ /** Wire up the Keep input + sentinel section */
1721
+ function wireUpKeepUI() {
1722
+ if (!$el)
1723
+ return;
1724
+ // Keep handle toggles the keep panel
1725
+ const keepHandle = $el.find('.gg-pipeline-keep-handle').get(0);
1726
+ const keepPanelEl = $el.find('.gg-keep-panel').get(0);
1727
+ if (keepHandle && keepPanelEl) {
1728
+ keepHandle.addEventListener('click', () => {
1729
+ const open = keepPanelEl.style.display !== 'none';
1730
+ keepPanelEl.style.display = open ? 'none' : '';
1731
+ keepHandle.classList.toggle('active', !open);
1732
+ // Close show panel when opening keep
1733
+ if (!open) {
1734
+ const showPanel = $el?.find('.gg-show-panel').get(0);
1735
+ const showHandle = $el?.find('.gg-pipeline-show-handle').get(0);
1736
+ if (showPanel)
1737
+ showPanel.style.display = 'none';
1738
+ if (showHandle)
1739
+ showHandle.classList.remove('active');
1740
+ }
1741
+ });
1742
+ }
1743
+ // Buf node click: replace node text with an inline input to change capacity
1744
+ const bufNode = $el.find('.gg-pipeline-buf').get(0);
1745
+ if (bufNode) {
1746
+ bufNode.addEventListener('click', () => {
1747
+ // Already editing?
1748
+ if (bufNode.querySelector('.gg-buf-size-input'))
1749
+ return;
1750
+ const current = buffer.capacity;
1751
+ const input = document.createElement('input');
1752
+ input.type = 'number';
1753
+ input.className = 'gg-buf-size-input';
1754
+ input.value = String(current);
1755
+ input.min = '100';
1756
+ input.max = '100000';
1757
+ input.title = 'Buffer capacity (Enter to apply, Escape to cancel)';
1758
+ bufNode.textContent = '';
1759
+ bufNode.appendChild(input);
1760
+ input.focus();
1761
+ input.select();
1762
+ const restore = () => {
1763
+ input.remove();
1764
+ renderPipelineUI(); // restores text content
1765
+ };
1766
+ const apply = () => {
1767
+ const val = parseInt(input.value, 10);
1768
+ if (!isNaN(val) && val > 0 && val !== current) {
1769
+ buffer.resize(val);
1770
+ localStorage.setItem(BUFFER_CAP_KEY, String(val));
1771
+ renderLogs();
1772
+ }
1773
+ restore();
1774
+ };
1775
+ input.addEventListener('blur', apply);
1776
+ input.addEventListener('keydown', (e) => {
1777
+ if (e.key === 'Enter') {
1778
+ e.preventDefault();
1779
+ input.blur();
1780
+ }
1781
+ if (e.key === 'Escape') {
1782
+ input.removeEventListener('blur', apply);
1783
+ restore();
1784
+ }
1785
+ });
1786
+ // Stop click from immediately re-triggering
1787
+ input.addEventListener('click', (e) => e.stopPropagation());
1788
+ });
1789
+ }
1790
+ const keepInput = $el.find('.gg-keep-input').get(0);
1791
+ if (keepInput) {
1792
+ keepInput.addEventListener('blur', () => {
1793
+ applyKeepPatternFromInput(keepInput.value);
1794
+ });
1795
+ keepInput.addEventListener('keydown', (e) => {
1796
+ if (e.key === 'Enter') {
1797
+ applyKeepPatternFromInput(keepInput.value);
1798
+ keepInput.blur();
1799
+ }
1800
+ });
1801
+ }
1802
+ // Wire up sentinel section: collapse toggle + [+] keep buttons
1803
+ const sentinelSection = $el.find('.gg-sentinel-section').get(0);
1804
+ if (sentinelSection) {
1805
+ sentinelSection.addEventListener('click', (e) => {
1806
+ const target = e.target;
1807
+ // Header click: toggle collapse
1808
+ if (target?.closest?.('.gg-sentinel-header')) {
1809
+ sentinelExpanded = !sentinelExpanded;
1810
+ renderSentinelSection();
1811
+ return;
1812
+ }
1813
+ const keepBtn = target?.closest?.('.gg-sentinel-keep');
1814
+ if (keepBtn) {
1815
+ const namespace = keepBtn.getAttribute('data-namespace');
1816
+ if (!namespace)
1817
+ return;
1818
+ const info = droppedNamespaces.get(namespace);
1819
+ if (!info)
1820
+ return;
1821
+ const previousKeep = keepPattern || 'gg:*';
1822
+ // Remove the exact exclusion for this namespace from keepPattern.
1823
+ // If no exclusion exists (Case B: namespace simply not included), add it.
1824
+ const currentKeep = keepPattern || 'gg:*';
1825
+ const exclusion = `-${namespace}`;
1826
+ const parts = currentKeep
1827
+ .split(',')
1828
+ .map((p) => p.trim())
1829
+ .filter(Boolean);
1830
+ const hasExclusion = parts.includes(exclusion);
1831
+ if (hasExclusion) {
1832
+ keepPattern = parts.filter((p) => p !== exclusion).join(',') || 'gg:*';
1833
+ }
1834
+ else {
1835
+ // No explicit exclusion — add an inclusion for the exact namespace
1836
+ keepPattern = parts.some((p) => !p.startsWith('-'))
1837
+ ? `${currentKeep},${namespace}`
1838
+ : `gg:*,${namespace}`;
1839
+ }
1840
+ keepPattern = simplifyPattern(keepPattern);
1841
+ localStorage.setItem(KEEP_KEY, keepPattern);
1842
+ // Sync keep input
1843
+ if (keepInput)
1844
+ keepInput.value = keepPattern;
1845
+ // Remove from droppedNamespaces map so sentinel disappears immediately
1846
+ droppedNamespaces.delete(namespace);
1847
+ renderKeepUI();
1848
+ renderSentinelSection();
1849
+ // Show keep toast for undo / segment broadening
1850
+ showKeepToast(namespace, previousKeep, info);
1851
+ }
1852
+ });
1853
+ }
1854
+ // Wire up Keep panel checkboxes
1855
+ const keepPanel = $el.find('.gg-keep-filter-panel').get(0);
1856
+ if (keepPanel) {
1857
+ keepPanel.addEventListener('change', (e) => {
1858
+ const target = e.target;
1859
+ if (target.classList.contains('gg-keep-all-checkbox')) {
1860
+ const allNs = [...allNamespacesSet, ...droppedNamespaces.keys()];
1861
+ if (target.checked) {
1862
+ // Keep all: remove all exclusions
1863
+ keepPattern = 'gg:*';
1864
+ droppedNamespaces.clear();
1865
+ }
1866
+ else {
1867
+ // Drop all
1868
+ const exclusions = allNs.map((ns) => `-${ns}`).join(',');
1869
+ keepPattern = `gg:*,${exclusions}`;
1870
+ keepPattern = simplifyPattern(keepPattern) || 'gg:*';
1871
+ }
1872
+ localStorage.setItem(KEEP_KEY, keepPattern);
1873
+ renderKeepUI();
1874
+ renderLogs();
1875
+ renderSentinelSection();
1876
+ return;
1877
+ }
1878
+ if (target.classList.contains('gg-keep-other-checkbox')) {
1879
+ const otherNs = JSON.parse(target.getAttribute('data-other-namespaces') || '[]');
1880
+ otherNs.forEach((ns) => toggleKeepNamespace(ns, target.checked));
1881
+ renderKeepUI();
1882
+ renderLogs();
1883
+ renderSentinelSection();
1884
+ return;
1885
+ }
1886
+ if (target.classList.contains('gg-keep-ns-checkbox')) {
1887
+ const namespace = target.getAttribute('data-namespace');
1888
+ if (!namespace)
1889
+ return;
1890
+ toggleKeepNamespace(namespace, target.checked);
1891
+ renderKeepUI();
1892
+ renderLogs();
1893
+ renderSentinelSection();
1894
+ }
1895
+ });
1896
+ }
1897
+ renderKeepUI();
1210
1898
  }
1211
1899
  /** Render the format field + $ROOT field shared by "Copy to clipboard" and "Open as URL" */
1212
1900
  function renderFormatSection(isUrlMode) {
@@ -1277,34 +1965,17 @@ export function createGgPlugin(options, gg) {
1277
1965
  optionsSection = renderFormatSection(nsClickAction === 'open-url');
1278
1966
  }
1279
1967
  // Native Console section
1280
- const currentDebugValue = localStorage.getItem('debug');
1281
- const debugDisplay = currentDebugValue !== null ? `'${escapeHtml(currentDebugValue)}'` : 'not set';
1282
- const currentFilter = filterPattern || 'gg:*';
1283
- const debugMatchesFilter = currentDebugValue === currentFilter;
1284
- const debugIncludesGg = currentDebugValue !== null &&
1285
- (currentDebugValue.includes('gg:') || currentDebugValue === '*');
1286
1968
  const nativeConsoleSection = `
1287
1969
  <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #ddd;">
1288
1970
  <div class="gg-settings-label">Native Console Output</div>
1289
1971
  <div style="font-size: 11px; opacity: 0.7; margin-bottom: 8px;">
1290
- gg messages always appear in this GG panel. To also see them in the browser's native console, set <code>localStorage.debug</code> below.
1291
- For server-side: <code>DEBUG=gg:* npm run dev</code>
1292
- </div>
1293
- <div style="font-family: monospace; font-size: 12px; margin-bottom: 6px;">
1294
- localStorage.debug = ${debugDisplay}
1295
- ${debugIncludesGg ? '<span style="color: green;">✅</span>' : '<span style="color: #999;">⚫ gg:* not included</span>'}
1296
- </div>
1297
- <div style="display: flex; gap: 6px; flex-wrap: wrap;">
1298
- <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' : ''}>
1299
- ${debugMatchesFilter ? 'In sync' : `Set to '${escapeHtml(currentFilter)}'`}
1300
- </button>
1301
- <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' : ''}>
1302
- Clear
1303
- </button>
1304
- </div>
1305
- <div style="font-size: 10px; opacity: 0.5; margin-top: 4px;">
1306
- Changes take effect on next page reload.
1972
+ When enabled, loggs shown in the GG panel are also output to the browser's native console (filtered by the Show pattern).
1307
1973
  </div>
1974
+ <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px;">
1975
+ <input type="checkbox" class="gg-console-toggle" ${ggConsoleEnabled ? 'checked' : ''}>
1976
+ Native console output
1977
+ </label>
1978
+ ${ggConsoleEnabled ? `<div style="font-size: 10px; opacity: 0.5; margin-top: 4px;">Disable to silence gg loggs in DevTools console.</div>` : ''}
1308
1979
  </div>
1309
1980
  `;
1310
1981
  settingsPanel.innerHTML = `
@@ -1343,7 +2014,6 @@ export function createGgPlugin(options, gg) {
1343
2014
  settingsBtn.addEventListener('click', () => {
1344
2015
  settingsExpanded = !settingsExpanded;
1345
2016
  if (settingsExpanded) {
1346
- filterExpanded = false;
1347
2017
  renderFilterUI();
1348
2018
  renderLogs();
1349
2019
  }
@@ -1403,15 +2073,21 @@ export function createGgPlugin(options, gg) {
1403
2073
  renderSettingsUI();
1404
2074
  }
1405
2075
  }
1406
- // Native Console: sync localStorage.debug to current gg-filter
1407
- if (target.classList.contains('gg-sync-debug-btn')) {
1408
- const currentFilter = localStorage.getItem(FILTER_KEY) || 'gg:*';
1409
- localStorage.setItem('debug', currentFilter);
1410
- renderSettingsUI();
1411
- }
1412
- // Native Console: clear localStorage.debug
1413
- if (target.classList.contains('gg-clear-debug-btn')) {
1414
- localStorage.removeItem('debug');
2076
+ // Native Console: gg-console toggle
2077
+ if (target.classList.contains('gg-console-toggle')) {
2078
+ const checked = target.checked;
2079
+ ggConsoleEnabled = checked;
2080
+ localStorage.setItem(CONSOLE_KEY, String(checked));
2081
+ // Update the debug factory's enabled pattern immediately
2082
+ import('../debug/index.js').then(({ default: dbg }) => {
2083
+ try {
2084
+ const pattern = ggConsoleEnabled ? filterPattern || 'gg:*' : '';
2085
+ dbg.enable(pattern);
2086
+ }
2087
+ catch {
2088
+ // ignore
2089
+ }
2090
+ });
1415
2091
  renderSettingsUI();
1416
2092
  }
1417
2093
  });
@@ -1422,7 +2098,10 @@ export function createGgPlugin(options, gg) {
1422
2098
  $el.find('.gg-clear-btn').on('click', () => {
1423
2099
  buffer.clear();
1424
2100
  allNamespacesSet.clear();
2101
+ droppedNamespaces.clear();
1425
2102
  renderLogs();
2103
+ renderSentinelSection();
2104
+ renderKeepUI();
1426
2105
  });
1427
2106
  $el.find('.gg-copy-btn').on('click', async () => {
1428
2107
  const allEntries = buffer.getEntries();
@@ -1507,14 +2186,8 @@ export function createGgPlugin(options, gg) {
1507
2186
  }
1508
2187
  }
1509
2188
  /** Show toast bar after hiding a namespace via the x button */
1510
- function showHideToast(namespace, previousPattern) {
1511
- if (!$el)
1512
- return;
1513
- lastHiddenPattern = previousPattern;
1514
- const toast = $el.find('.gg-toast').get(0);
1515
- if (!toast)
1516
- return;
1517
- // Split namespace into segments with delimiters (same logic as log row rendering)
2189
+ /** Build clickable segment HTML for a namespace (shared by hide/drop toasts) */
2190
+ function buildToastNsHTML(namespace) {
1518
2191
  const parts = namespace.split(/([:/@ \-_])/);
1519
2192
  const segments = [];
1520
2193
  const delimiters = [];
@@ -1527,27 +2200,31 @@ export function createGgPlugin(options, gg) {
1527
2200
  delimiters.push(parts[i]);
1528
2201
  }
1529
2202
  }
1530
- // Build clickable segment HTML
1531
2203
  let nsHTML = '';
1532
2204
  for (let i = 0; i < segments.length; i++) {
1533
- const segment = escapeHtml(segments[i]);
1534
- // Build filter pattern for this segment level
1535
2205
  let segFilter = '';
1536
2206
  for (let j = 0; j <= i; j++) {
1537
2207
  segFilter += segments[j];
1538
- if (j < i) {
2208
+ if (j < i)
1539
2209
  segFilter += delimiters[j];
1540
- }
1541
- else if (j < segments.length - 1) {
2210
+ else if (j < segments.length - 1)
1542
2211
  segFilter += delimiters[j] + '*';
1543
- }
1544
2212
  }
1545
- nsHTML += `<span class="gg-toast-segment" data-filter="${escapeHtml(segFilter)}">${segment}</span>`;
1546
- if (i < segments.length - 1) {
2213
+ nsHTML += `<span class="gg-toast-segment" data-filter="${escapeHtml(segFilter)}">${escapeHtml(segments[i])}</span>`;
2214
+ if (i < segments.length - 1)
1547
2215
  nsHTML += `<span class="gg-toast-delim">${escapeHtml(delimiters[i])}</span>`;
1548
- }
1549
2216
  }
1550
- // Auto-expand explanation on first use
2217
+ return nsHTML;
2218
+ }
2219
+ function showHideToast(namespace, previousPattern) {
2220
+ if (!$el)
2221
+ return;
2222
+ toastMode = 'hide';
2223
+ lastHiddenPattern = previousPattern;
2224
+ const toast = $el.find('.gg-toast').get(0);
2225
+ if (!toast)
2226
+ return;
2227
+ const nsHTML = buildToastNsHTML(namespace);
1551
2228
  const showExplanation = !hasSeenToastExplanation;
1552
2229
  toast.innerHTML =
1553
2230
  `<button class="gg-toast-btn gg-toast-dismiss" title="Dismiss">\u00d7</button>` +
@@ -1558,13 +2235,64 @@ export function createGgPlugin(options, gg) {
1558
2235
  `<button class="gg-toast-btn gg-toast-help" title="Toggle help">?</button>` +
1559
2236
  `</span>` +
1560
2237
  `<div class="gg-toast-explanation${showExplanation ? ' visible' : ''}">` +
1561
- `Click a segment above to hide all matching namespaces (e.g. click "api" to hide gg:api:*). ` +
2238
+ `Click a segment above to hide all matching namespaces (e.g. click "api" to hide api/*). ` +
1562
2239
  `Tip: you can also right-click any segment in the log to hide it directly.` +
1563
2240
  `</div>`;
1564
2241
  toast.classList.add('visible');
1565
- if (showExplanation) {
2242
+ if (showExplanation)
2243
+ hasSeenToastExplanation = true;
2244
+ }
2245
+ function showDropToast(namespace, previousKeep) {
2246
+ if (!$el)
2247
+ return;
2248
+ toastMode = 'drop';
2249
+ lastDroppedPattern = previousKeep;
2250
+ const toast = $el.find('.gg-toast').get(0);
2251
+ if (!toast)
2252
+ return;
2253
+ const nsHTML = buildToastNsHTML(namespace);
2254
+ const showExplanation = !hasSeenToastExplanation;
2255
+ toast.innerHTML =
2256
+ `<button class="gg-toast-btn gg-toast-dismiss" title="Dismiss">\u00d7</button>` +
2257
+ `<span class="gg-toast-label">Dropped:</span>` +
2258
+ `<span class="gg-toast-ns">${nsHTML}</span>` +
2259
+ `<span class="gg-toast-actions">` +
2260
+ `<button class="gg-toast-btn gg-toast-undo">Undo</button>` +
2261
+ `<button class="gg-toast-btn gg-toast-help" title="Toggle help">?</button>` +
2262
+ `</span>` +
2263
+ `<div class="gg-toast-explanation${showExplanation ? ' visible' : ''}">` +
2264
+ `Click a segment above to drop all matching namespaces from the buffer (e.g. click "api" to drop api/*).` +
2265
+ `</div>`;
2266
+ toast.classList.add('visible');
2267
+ if (showExplanation)
2268
+ hasSeenToastExplanation = true;
2269
+ }
2270
+ function showKeepToast(namespace, previousKeep, info) {
2271
+ if (!$el)
2272
+ return;
2273
+ toastMode = 'keep';
2274
+ lastKeptPattern = previousKeep;
2275
+ lastKeptNamespaceInfo = { ns: namespace, info };
2276
+ const toast = $el.find('.gg-toast').get(0);
2277
+ if (!toast)
2278
+ return;
2279
+ const nsHTML = buildToastNsHTML(namespace);
2280
+ const showExplanation = !hasSeenToastExplanation;
2281
+ toast.innerHTML =
2282
+ `<button class="gg-toast-btn gg-toast-dismiss" title="Dismiss">\u00d7</button>` +
2283
+ `<span class="gg-toast-label">Kept:</span>` +
2284
+ `<span class="gg-toast-ns">${nsHTML}</span>` +
2285
+ `<span class="gg-toast-actions">` +
2286
+ `<button class="gg-toast-btn gg-toast-undo">Undo</button>` +
2287
+ `<button class="gg-toast-btn gg-toast-help" title="Toggle help">?</button>` +
2288
+ `</span>` +
2289
+ `<div class="gg-toast-explanation${showExplanation ? ' visible' : ''}">` +
2290
+ `Click a segment above to keep all matching namespaces (e.g. click "api" to keep api/*). ` +
2291
+ `Only new loggs from this point forward will be captured.` +
2292
+ `</div>`;
2293
+ toast.classList.add('visible');
2294
+ if (showExplanation)
1566
2295
  hasSeenToastExplanation = true;
1567
- }
1568
2296
  }
1569
2297
  /** Dismiss the toast bar */
1570
2298
  function dismissToast() {
@@ -1575,25 +2303,64 @@ export function createGgPlugin(options, gg) {
1575
2303
  toast.classList.remove('visible');
1576
2304
  }
1577
2305
  lastHiddenPattern = null;
2306
+ lastDroppedPattern = null;
2307
+ lastKeptPattern = null;
2308
+ lastKeptNamespaceInfo = null;
1578
2309
  }
1579
- /** Undo the last namespace hide */
2310
+ /** Undo the last namespace hide, drop, or keep */
1580
2311
  function undoHide() {
1581
- if (!$el || lastHiddenPattern === null)
2312
+ if (!$el)
1582
2313
  return;
1583
- // Restore the previous filter pattern
1584
- filterPattern = lastHiddenPattern;
1585
- localStorage.setItem(FILTER_KEY, filterPattern);
1586
- // Sync enabledNamespaces from the restored pattern
1587
- enabledNamespaces.clear();
1588
- const effectivePattern = filterPattern || 'gg:*';
1589
- getAllCapturedNamespaces().forEach((ns) => {
1590
- if (namespaceMatchesPattern(ns, effectivePattern)) {
1591
- enabledNamespaces.add(ns);
1592
- }
1593
- });
1594
- dismissToast();
1595
- renderFilterUI();
1596
- renderLogs();
2314
+ if (toastMode === 'keep' && lastKeptPattern !== null) {
2315
+ // Restore keepPattern
2316
+ keepPattern = lastKeptPattern;
2317
+ localStorage.setItem(KEEP_KEY, keepPattern);
2318
+ const keepInput = $el.find('.gg-keep-input').get(0);
2319
+ if (keepInput)
2320
+ keepInput.value = keepPattern;
2321
+ // Re-insert the sentinel entry so it reappears
2322
+ if (lastKeptNamespaceInfo) {
2323
+ droppedNamespaces.set(lastKeptNamespaceInfo.ns, lastKeptNamespaceInfo.info);
2324
+ }
2325
+ dismissToast();
2326
+ renderKeepUI();
2327
+ renderSentinelSection();
2328
+ renderLogs();
2329
+ }
2330
+ else if (toastMode === 'drop' && lastDroppedPattern !== null) {
2331
+ // Restore keepPattern
2332
+ keepPattern = lastDroppedPattern;
2333
+ localStorage.setItem(KEEP_KEY, keepPattern);
2334
+ const keepInput = $el.find('.gg-keep-input').get(0);
2335
+ if (keepInput)
2336
+ keepInput.value = keepPattern;
2337
+ // Restore enabledNamespaces from current show filter
2338
+ enabledNamespaces.clear();
2339
+ const effectiveShow = filterPattern || 'gg:*';
2340
+ getAllCapturedNamespaces().forEach((ns) => {
2341
+ if (namespaceMatchesPattern(ns, effectiveShow))
2342
+ enabledNamespaces.add(ns);
2343
+ });
2344
+ dismissToast();
2345
+ renderKeepUI();
2346
+ renderFilterUI();
2347
+ renderLogs();
2348
+ }
2349
+ else if (toastMode === 'hide' && lastHiddenPattern !== null) {
2350
+ // Restore filterPattern
2351
+ filterPattern = lastHiddenPattern;
2352
+ localStorage.setItem(SHOW_KEY, filterPattern);
2353
+ enabledNamespaces.clear();
2354
+ const effectivePattern = filterPattern || 'gg:*';
2355
+ getAllCapturedNamespaces().forEach((ns) => {
2356
+ if (namespaceMatchesPattern(ns, effectivePattern))
2357
+ enabledNamespaces.add(ns);
2358
+ });
2359
+ dismissToast();
2360
+ renderFilterUI();
2361
+ renderSentinelSection();
2362
+ renderLogs();
2363
+ }
1597
2364
  }
1598
2365
  /** Wire up toast event handlers (called once after init) */
1599
2366
  function wireUpToast() {
@@ -1622,41 +2389,101 @@ export function createGgPlugin(options, gg) {
1622
2389
  }
1623
2390
  return;
1624
2391
  }
1625
- // Segment click: add exclusion for that pattern
2392
+ // Segment click: add exclusion to the appropriate layer
1626
2393
  if (target.classList?.contains('gg-toast-segment')) {
1627
2394
  const filter = target.getAttribute('data-filter');
1628
2395
  if (!filter)
1629
2396
  return;
1630
- // Add exclusion pattern (same logic as right-click segment)
1631
- const currentPattern = filterPattern || 'gg:*';
1632
2397
  const exclusion = `-${filter}`;
1633
- const parts = currentPattern.split(',').map((p) => p.trim());
1634
- if (parts.includes(exclusion)) {
1635
- // Already excluded, toggle off
1636
- filterPattern = parts.filter((p) => p !== exclusion).join(',') || 'gg:*';
2398
+ if (toastMode === 'keep') {
2399
+ // Broaden the keep: remove all exclusions whose prefix matches `filter`,
2400
+ // then add `filter` as an inclusion if pattern has no wildcard base.
2401
+ const currentKeep = keepPattern || 'gg:*';
2402
+ const keepParts = currentKeep
2403
+ .split(',')
2404
+ .map((p) => p.trim())
2405
+ .filter(Boolean);
2406
+ // Remove any exclusion that is a sub-pattern of filter (starts with -filter prefix)
2407
+ const narrowed = keepParts.filter((p) => {
2408
+ if (!p.startsWith('-'))
2409
+ return true;
2410
+ const excl = p.slice(1).replace(/\*$/, '');
2411
+ const filterBase = filter.replace(/\*$/, '');
2412
+ return !excl.startsWith(filterBase) && !filterBase.startsWith(excl);
2413
+ });
2414
+ const hasWildcardBase = narrowed.some((p) => p === '*' || p === 'gg:*');
2415
+ if (!hasWildcardBase &&
2416
+ !narrowed.some((p) => !p.startsWith('-') && namespaceMatchesPattern(filter.replace(/\*$/, 'x'), p))) {
2417
+ narrowed.push(filter);
2418
+ }
2419
+ keepPattern = simplifyPattern(narrowed.join(',') || 'gg:*');
2420
+ localStorage.setItem(KEEP_KEY, keepPattern);
2421
+ const keepInput = $el?.find('.gg-keep-input').get(0);
2422
+ if (keepInput)
2423
+ keepInput.value = keepPattern;
2424
+ // Remove all dropped namespaces that now match the new keepPattern
2425
+ for (const ns of [...droppedNamespaces.keys()]) {
2426
+ if (namespaceMatchesPattern(ns, keepPattern)) {
2427
+ droppedNamespaces.delete(ns);
2428
+ }
2429
+ }
2430
+ dismissToast();
2431
+ renderKeepUI();
2432
+ renderSentinelSection();
2433
+ renderLogs();
2434
+ }
2435
+ else if (toastMode === 'drop') {
2436
+ // Add exclusion to keepPattern (Layer 1)
2437
+ const currentKeep = keepPattern || 'gg:*';
2438
+ const keepParts = currentKeep.split(',').map((p) => p.trim());
2439
+ if (!keepParts.includes(exclusion)) {
2440
+ keepPattern = keepParts.some((p) => !p.startsWith('-'))
2441
+ ? `${currentKeep},${exclusion}`
2442
+ : `gg:*,${exclusion}`;
2443
+ keepPattern = simplifyPattern(keepPattern);
2444
+ }
2445
+ localStorage.setItem(KEEP_KEY, keepPattern);
2446
+ const keepInput = $el?.find('.gg-keep-input').get(0);
2447
+ if (keepInput)
2448
+ keepInput.value = keepPattern;
2449
+ // Remove newly-dropped namespace from visible list
2450
+ // (the segment pattern may match multiple namespaces)
2451
+ getAllCapturedNamespaces().forEach((ns) => {
2452
+ if (!namespaceMatchesPattern(ns, keepPattern)) {
2453
+ enabledNamespaces.delete(ns);
2454
+ }
2455
+ });
2456
+ dismissToast();
2457
+ renderKeepUI();
2458
+ renderFilterUI();
2459
+ renderLogs();
2460
+ scheduleSentinelRender();
1637
2461
  }
1638
2462
  else {
1639
- const hasInclusion = parts.some((p) => !p.startsWith('-'));
1640
- if (hasInclusion) {
1641
- filterPattern = `${currentPattern},${exclusion}`;
2463
+ // toastMode === 'hide': add exclusion to filterPattern (Layer 2)
2464
+ const currentPattern = filterPattern || 'gg:*';
2465
+ const parts = currentPattern.split(',').map((p) => p.trim());
2466
+ if (parts.includes(exclusion)) {
2467
+ filterPattern = parts.filter((p) => p !== exclusion).join(',') || 'gg:*';
1642
2468
  }
1643
2469
  else {
1644
- filterPattern = `gg:*,${exclusion}`;
2470
+ filterPattern = parts.some((p) => !p.startsWith('-'))
2471
+ ? `${currentPattern},${exclusion}`
2472
+ : `gg:*,${exclusion}`;
1645
2473
  }
2474
+ filterPattern = simplifyPattern(filterPattern);
2475
+ enabledNamespaces.clear();
2476
+ const effectivePattern = filterPattern || 'gg:*';
2477
+ getAllCapturedNamespaces().forEach((ns) => {
2478
+ if (namespaceMatchesPattern(ns, effectivePattern))
2479
+ enabledNamespaces.add(ns);
2480
+ });
2481
+ localStorage.setItem(SHOW_KEY, filterPattern);
2482
+ dismissToast();
2483
+ renderFilterUI();
2484
+ renderSentinelSection();
2485
+ renderLogs();
1646
2486
  }
1647
- filterPattern = simplifyPattern(filterPattern);
1648
- // Sync enabledNamespaces
1649
- enabledNamespaces.clear();
1650
- const effectivePattern = filterPattern || 'gg:*';
1651
- getAllCapturedNamespaces().forEach((ns) => {
1652
- if (namespaceMatchesPattern(ns, effectivePattern)) {
1653
- enabledNamespaces.add(ns);
1654
- }
1655
- });
1656
- localStorage.setItem(FILTER_KEY, filterPattern);
1657
- dismissToast();
1658
- renderFilterUI();
1659
- renderLogs();
1660
2487
  return;
1661
2488
  }
1662
2489
  });
@@ -1671,13 +2498,25 @@ export function createGgPlugin(options, gg) {
1671
2498
  return;
1672
2499
  containerEl.addEventListener('click', (e) => {
1673
2500
  const target = e.target;
1674
- // Handle reset filter button (shown when all logs filtered out)
2501
+ // Handle reset filter button (shown when all logs filtered out by gg-show)
1675
2502
  if (target?.classList?.contains('gg-reset-filter-btn')) {
1676
2503
  filterPattern = 'gg:*';
1677
2504
  enabledNamespaces.clear();
1678
2505
  getAllCapturedNamespaces().forEach((ns) => enabledNamespaces.add(ns));
1679
- localStorage.setItem(FILTER_KEY, filterPattern);
2506
+ localStorage.setItem(SHOW_KEY, filterPattern);
1680
2507
  renderFilterUI();
2508
+ renderSentinelSection();
2509
+ renderLogs();
2510
+ return;
2511
+ }
2512
+ // Handle keep-all button (shown when gg-keep is restrictive and buffer is empty)
2513
+ if (target?.classList?.contains('gg-keep-all-btn')) {
2514
+ keepPattern = 'gg:*';
2515
+ localStorage.setItem(KEEP_KEY, keepPattern);
2516
+ const keepInput = $el?.find('.gg-keep-input').get(0);
2517
+ if (keepInput)
2518
+ keepInput.value = keepPattern;
2519
+ renderKeepUI();
1681
2520
  renderLogs();
1682
2521
  return;
1683
2522
  }
@@ -1748,8 +2587,9 @@ export function createGgPlugin(options, gg) {
1748
2587
  .filter((ns) => namespaceMatchesPattern(ns, filter))
1749
2588
  .forEach((ns) => enabledNamespaces.add(ns));
1750
2589
  }
1751
- localStorage.setItem(FILTER_KEY, filterPattern);
2590
+ localStorage.setItem(SHOW_KEY, filterPattern);
1752
2591
  renderFilterUI();
2592
+ renderSentinelSection();
1753
2593
  renderLogs();
1754
2594
  return;
1755
2595
  }
@@ -1758,41 +2598,65 @@ export function createGgPlugin(options, gg) {
1758
2598
  handleNamespaceClick(target);
1759
2599
  return;
1760
2600
  }
2601
+ // Handle clicking drop button for namespace (Layer 1: gg-keep)
2602
+ const dropBtn = target?.closest?.('.gg-ns-drop');
2603
+ if (dropBtn) {
2604
+ const namespace = dropBtn.getAttribute('data-namespace');
2605
+ if (!namespace)
2606
+ return;
2607
+ const currentKeep = keepPattern || 'gg:*';
2608
+ const exclusion = `-${namespace}`;
2609
+ const parts = currentKeep.split(',').map((p) => p.trim());
2610
+ if (!parts.includes(exclusion)) {
2611
+ const previousKeep = keepPattern;
2612
+ keepPattern = `${currentKeep},${exclusion}`;
2613
+ keepPattern = simplifyPattern(keepPattern);
2614
+ localStorage.setItem(KEEP_KEY, keepPattern);
2615
+ // Sync keep input
2616
+ const keepInput = $el?.find('.gg-keep-input').get(0);
2617
+ if (keepInput)
2618
+ keepInput.value = keepPattern;
2619
+ // Remove from visible loggs (Layer 1 drop hides from display too)
2620
+ enabledNamespaces.delete(namespace);
2621
+ renderKeepUI();
2622
+ renderFilterUI();
2623
+ renderLogs();
2624
+ scheduleSentinelRender();
2625
+ showDropToast(namespace, previousKeep);
2626
+ }
2627
+ return;
2628
+ }
1761
2629
  // Handle clicking hide button for namespace
1762
- if (target?.classList?.contains('gg-ns-hide')) {
1763
- const namespace = target.getAttribute('data-namespace');
2630
+ const hideBtn = target?.closest?.('.gg-ns-hide');
2631
+ if (hideBtn) {
2632
+ const namespace = hideBtn.getAttribute('data-namespace');
1764
2633
  if (!namespace)
1765
2634
  return;
1766
2635
  // Save current pattern for undo before hiding
1767
2636
  const previousPattern = filterPattern;
1768
2637
  toggleNamespace(namespace, false);
1769
- localStorage.setItem(FILTER_KEY, filterPattern);
2638
+ localStorage.setItem(SHOW_KEY, filterPattern);
1770
2639
  renderFilterUI();
2640
+ renderSentinelSection();
1771
2641
  renderLogs();
1772
2642
  // Show toast with undo option
1773
2643
  showHideToast(namespace, previousPattern);
1774
2644
  return;
1775
2645
  }
1776
- // Clicking background (container or grid, not a log element) restores all
1777
- if (filterExpanded &&
1778
- filterPattern !== 'gg:*' &&
2646
+ // Clicking background (container or grid, not a log entry) restores show filter
2647
+ if (filterPattern !== 'gg:*' &&
1779
2648
  (target === containerEl || target?.classList?.contains('gg-log-grid'))) {
1780
2649
  filterPattern = 'gg:*';
1781
2650
  enabledNamespaces.clear();
1782
2651
  getAllCapturedNamespaces().forEach((ns) => enabledNamespaces.add(ns));
1783
- localStorage.setItem(FILTER_KEY, filterPattern);
2652
+ localStorage.setItem(SHOW_KEY, filterPattern);
1784
2653
  renderFilterUI();
2654
+ renderSentinelSection();
1785
2655
  renderLogs();
1786
2656
  }
1787
2657
  });
1788
- // Helper: show confirmation tooltip near target element
1789
- function showConfirmationTooltip(containerEl, target, text) {
1790
- const tip = containerEl.querySelector('.gg-hover-tooltip');
1791
- if (!tip)
1792
- return;
1793
- tip.textContent = text;
1794
- tip.style.display = 'block';
1795
- const targetRect = target.getBoundingClientRect();
2658
+ /** Clamp and apply tooltip position below (or above) a target element. */
2659
+ function positionTooltip(tip, targetRect) {
1796
2660
  let left = targetRect.left;
1797
2661
  let top = targetRect.bottom + 4;
1798
2662
  const tipRect = tip.getBoundingClientRect();
@@ -1806,6 +2670,15 @@ export function createGgPlugin(options, gg) {
1806
2670
  }
1807
2671
  tip.style.left = `${left}px`;
1808
2672
  tip.style.top = `${top}px`;
2673
+ }
2674
+ // Helper: show confirmation tooltip near target element
2675
+ function showConfirmationTooltip(containerEl, target, text) {
2676
+ const tip = containerEl.querySelector('.gg-hover-tooltip');
2677
+ if (!tip)
2678
+ return;
2679
+ tip.textContent = text;
2680
+ tip.style.display = 'block';
2681
+ positionTooltip(tip, target.getBoundingClientRect());
1809
2682
  setTimeout(() => {
1810
2683
  tip.style.display = 'none';
1811
2684
  }, 1500);
@@ -1847,8 +2720,9 @@ export function createGgPlugin(options, gg) {
1847
2720
  enabledNamespaces.add(ns);
1848
2721
  }
1849
2722
  });
1850
- localStorage.setItem(FILTER_KEY, filterPattern);
2723
+ localStorage.setItem(SHOW_KEY, filterPattern);
1851
2724
  renderFilterUI();
2725
+ renderSentinelSection();
1852
2726
  renderLogs();
1853
2727
  return;
1854
2728
  }
@@ -1916,23 +2790,7 @@ export function createGgPlugin(options, gg) {
1916
2790
  pre.textContent = JSON.stringify(arg, null, 2);
1917
2791
  tip.appendChild(pre);
1918
2792
  tip.style.display = 'block';
1919
- // Position below the hovered element using viewport coords (fixed positioning)
1920
- const targetRect = target.getBoundingClientRect();
1921
- let left = targetRect.left;
1922
- let top = targetRect.bottom + 4;
1923
- // Keep tooltip within viewport
1924
- const tipRect = tip.getBoundingClientRect();
1925
- if (left + tipRect.width > window.innerWidth) {
1926
- left = window.innerWidth - tipRect.width - 8;
1927
- }
1928
- if (left < 4)
1929
- left = 4;
1930
- // If tooltip would go below viewport, show above instead
1931
- if (top + tipRect.height > window.innerHeight) {
1932
- top = targetRect.top - tipRect.height - 4;
1933
- }
1934
- tip.style.left = `${left}px`;
1935
- tip.style.top = `${top}px`;
2793
+ positionTooltip(tip, target.getBoundingClientRect());
1936
2794
  });
1937
2795
  containerEl.addEventListener('mouseout', (e) => {
1938
2796
  const target = e.target?.closest?.('.gg-expand');
@@ -1972,22 +2830,7 @@ export function createGgPlugin(options, gg) {
1972
2830
  }
1973
2831
  tip.textContent = actionText;
1974
2832
  tip.style.display = 'block';
1975
- // Position below the target
1976
- const targetRect = target.getBoundingClientRect();
1977
- let left = targetRect.left;
1978
- let top = targetRect.bottom + 4;
1979
- // Keep tooltip within viewport
1980
- const tipRect = tip.getBoundingClientRect();
1981
- if (left + tipRect.width > window.innerWidth) {
1982
- left = window.innerWidth - tipRect.width - 8;
1983
- }
1984
- if (left < 4)
1985
- left = 4;
1986
- if (top + tipRect.height > window.innerHeight) {
1987
- top = targetRect.top - tipRect.height - 4;
1988
- }
1989
- tip.style.left = `${left}px`;
1990
- tip.style.top = `${top}px`;
2833
+ positionTooltip(tip, target.getBoundingClientRect());
1991
2834
  });
1992
2835
  containerEl.addEventListener('mouseout', (e) => {
1993
2836
  const target = e.target;
@@ -2178,31 +3021,13 @@ export function createGgPlugin(options, gg) {
2178
3021
  return (`<div class="gg-log-entry${levelClass}" data-entry="${index}"${vindexAttr}>` +
2179
3022
  `<div class="gg-log-header">` +
2180
3023
  `<div class="gg-log-diff" style="color: ${color};"${fileAttr}${lineAttr}${colAttr}>${diff}</div>` +
2181
- `<div class="gg-log-ns" style="color: ${color};" data-namespace="${escapeHtml(entry.namespace)}"><span class="gg-ns-text">${nsHTML}</span><button class="gg-ns-hide" data-namespace="${escapeHtml(entry.namespace)}" title="Hide this namespace">\u00d7</button></div>` +
3024
+ `<div class="gg-log-ns" style="color: ${color};" data-namespace="${escapeHtml(entry.namespace)}"><span class="gg-ns-text">${nsHTML}</span><button class="gg-ns-drop" data-namespace="${escapeHtml(entry.namespace)}" title="Drop this namespace (stop buffering its loggs)">${ICON_DROP}</button><button class="gg-ns-hide" data-namespace="${escapeHtml(entry.namespace)}" title="Hide this namespace">${ICON_HIDE}</button></div>` +
2182
3025
  `<div class="gg-log-handle"></div>` +
2183
3026
  `</div>` +
2184
3027
  `<div class="gg-log-content"${hasSrcExpr ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${exprAboveForPrimitives}${argsHTML}${stackHTML}</div>` +
2185
3028
  detailsHTML +
2186
3029
  `</div>`);
2187
3030
  }
2188
- /** Update the copy-button count text */
2189
- function updateTruncationBanner() {
2190
- if (!$el)
2191
- return;
2192
- const banner = $el.find('.gg-truncation-banner').get(0);
2193
- if (!banner)
2194
- return;
2195
- const evicted = buffer.evicted;
2196
- if (evicted > 0) {
2197
- const total = buffer.totalPushed;
2198
- const retained = buffer.size;
2199
- banner.innerHTML = `⚠ Showing ${retained.toLocaleString()} of ${total.toLocaleString()} messages &mdash; ${evicted.toLocaleString()} truncated. Increase <code style="font-family:monospace;background:rgba(255,255,255,0.15);padding:0 3px;border-radius:3px;">maxEntries</code> to retain more.`;
2200
- banner.style.display = 'flex';
2201
- }
2202
- else {
2203
- banner.style.display = 'none';
2204
- }
2205
- }
2206
3031
  function updateCopyCount() {
2207
3032
  if (!$el)
2208
3033
  return;
@@ -2323,7 +3148,8 @@ export function createGgPlugin(options, gg) {
2323
3148
  // Mount the virtualizer (attaches scroll/resize observers)
2324
3149
  const cleanup = virtualizer._didMount();
2325
3150
  // Store cleanup for when we tear down (TODO: call on destroy)
2326
- containerDom.__ggVirtualCleanup = cleanup;
3151
+ containerDom.__ggVirtualCleanup =
3152
+ cleanup;
2327
3153
  // Initial render, then scroll to bottom if requested
2328
3154
  virtualizer._willUpdate();
2329
3155
  renderVirtualItems();
@@ -2354,6 +3180,7 @@ export function createGgPlugin(options, gg) {
2354
3180
  const hasVisible = newEntries.some((e) => enabledNamespaces.has(e.namespace));
2355
3181
  if (!hasVisible && buffer.evicted === 0) {
2356
3182
  updateCopyCount();
3183
+ renderPipelineUI();
2357
3184
  return;
2358
3185
  }
2359
3186
  // Rebuild filteredIndices from scratch. This is O(buffer.size) with a
@@ -2361,7 +3188,7 @@ export function createGgPlugin(options, gg) {
2361
3188
  // when the buffer wraps and old logical indices shift.
2362
3189
  rebuildFilteredIndices();
2363
3190
  updateCopyCount();
2364
- updateTruncationBanner();
3191
+ renderPipelineUI();
2365
3192
  // Check if user is near bottom before we update the virtualizer
2366
3193
  const nearBottom = containerDom.scrollHeight - containerDom.scrollTop - containerDom.clientHeight < 50;
2367
3194
  userNearBottom = nearBottom;
@@ -2398,33 +3225,46 @@ export function createGgPlugin(options, gg) {
2398
3225
  expandedStacks.clear();
2399
3226
  // Rebuild filtered indices from scratch
2400
3227
  rebuildFilteredIndices();
3228
+ renderPipelineUI();
2401
3229
  updateCopyCount();
2402
- updateTruncationBanner();
2403
3230
  if (filteredIndices.length === 0) {
2404
3231
  // Tear down virtualizer
2405
3232
  if (virtualizer) {
2406
3233
  const containerDom = logContainer.get(0);
2407
3234
  if (containerDom) {
2408
- const cleanup = containerDom.__ggVirtualCleanup;
3235
+ const cleanup = containerDom
3236
+ .__ggVirtualCleanup;
2409
3237
  if (cleanup)
2410
3238
  cleanup();
2411
3239
  }
2412
3240
  virtualizer = null;
2413
3241
  }
2414
3242
  const hasFilteredLogs = buffer.size > 0;
2415
- const message = hasFilteredLogs
2416
- ? `All ${buffer.size} logs filtered out.`
2417
- : 'No logs captured yet. Call gg() to see output here.';
2418
- const resetButton = hasFilteredLogs
2419
- ? '<button class="gg-reset-filter-btn" style="margin-top: 12px; padding: 10px 20px; cursor: pointer; border: 1px solid #2196F3; background: #2196F3; color: white; border-radius: 6px; font-size: 13px; font-weight: 500; transition: background 0.2s;">Show all logs (gg:*)</button>'
2420
- : '';
2421
- logContainer.html(`<div style="padding: 20px; text-align: center; opacity: 0.5;">${message}<div>${resetButton}</div></div>`);
3243
+ const keepIsRestrictive = (keepPattern || 'gg:*') !== 'gg:*' && (keepPattern || 'gg:*') !== '*';
3244
+ let message;
3245
+ let actionButton;
3246
+ if (hasFilteredLogs) {
3247
+ message = `All ${buffer.size} logs filtered out.`;
3248
+ actionButton =
3249
+ '<button class="gg-reset-filter-btn" style="margin-top: 12px; padding: 10px 20px; cursor: pointer; border: 1px solid #2196F3; background: #2196F3; color: white; border-radius: 6px; font-size: 13px; font-weight: 500; transition: background 0.2s;">Show all logs (gg:*)</button>';
3250
+ }
3251
+ else if (keepIsRestrictive) {
3252
+ message = 'No loggs kept.';
3253
+ actionButton =
3254
+ `<button class="gg-keep-all-btn" style="margin-top: 12px; padding: 10px 20px; cursor: pointer; border: 1px solid #4CAF50; background: #4CAF50; color: white; border-radius: 6px; font-size: 13px; font-weight: 500; transition: background 0.2s;">Keep All</button>` +
3255
+ `<div style="margin-top: 10px; font-size: 11px; opacity: 0.7;">gg-keep: ${escapeHtml(keepPattern || 'gg:*')}</div>`;
3256
+ }
3257
+ else {
3258
+ message = 'No loggs captured yet. Call gg() to see output here.';
3259
+ actionButton = '';
3260
+ }
3261
+ logContainer.html(`<div style="padding: 20px; text-align: center; opacity: 0.5;">${message}<div>${actionButton}</div></div>`);
2422
3262
  return;
2423
3263
  }
2424
3264
  // Build the virtual scroll DOM structure:
2425
3265
  // - .gg-virtual-spacer: sized to total virtual height (provides scrollbar)
2426
3266
  // - .gg-log-grid: positioned absolutely, translated to visible offset, holds only visible entries
2427
- const gridClasses = `gg-log-grid${filterExpanded ? ' filter-mode' : ''}${showExpressions ? ' gg-show-expr' : ''}`;
3267
+ const gridClasses = `gg-log-grid${showExpressions ? ' gg-show-expr' : ''}`;
2428
3268
  logContainer.html(`<div class="gg-virtual-spacer">` +
2429
3269
  `<div class="${gridClasses}" style="position: absolute; top: 0; left: 0; width: 100%; grid-template-columns: ${gridColumns()};"></div>` +
2430
3270
  `</div>` +