@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.
package/README.md CHANGED
@@ -273,16 +273,122 @@ import ggPlugins from '@leftium/gg/vite';
273
273
 
274
274
  ggPlugins({
275
275
  callSites: { srcRootPattern: '.*?(/src/)' },
276
- openInEditor: false // disable open-in-editor middleware
276
+ openInEditor: false, // disable open-in-editor middleware
277
+ fileSink: true // write all gg() calls to .gg/logs-{port}.jsonl (for coding agents)
277
278
  });
278
279
  ```
279
280
 
281
+ | Option | Type | Default | Description |
282
+ | -------------- | ----------------------------- | ------- | ------------------------------------------------------------- |
283
+ | `callSites` | `boolean \| CallSiteOptions` | `true` | Rewrites `gg()` calls with source file/line/col metadata |
284
+ | `openInEditor` | `boolean` | `true` | Adds dev server middleware for click-to-open source files |
285
+ | `fileSink` | `boolean \| { dir?: string }` | `false` | Writes all `gg()` calls to `.gg/logs-{port}.jsonl` (dev only) |
286
+
280
287
  Individual plugins are also available for advanced setups:
281
288
 
282
289
  ```ts
283
- import { ggCallSitesPlugin, openInEditorPlugin } from '@leftium/gg/vite';
290
+ import { ggCallSitesPlugin, openInEditorPlugin, ggFileSinkPlugin } from '@leftium/gg/vite';
291
+ ```
292
+
293
+ ## Coding Agent Access
294
+
295
+ With `fileSink: true`, all `gg()` calls -- both browser-side and server-side -- are written to `.gg/logs-{port}.jsonl` during development. Coding agents (Claude, Cursor, etc.) can read this file directly without clipboard, browser automation, or manual copy/paste.
296
+
297
+ Add `.gg/` to your `.gitignore` to keep log files out of version control.
298
+
299
+ **Enable in `vite.config.ts`:**
300
+
301
+ ```ts
302
+ ggPlugins({ fileSink: true });
284
303
  ```
285
304
 
305
+ **Typical agent workflow:**
306
+
307
+ ```bash
308
+ # 1. Clear logs before the action under investigation
309
+ curl -X DELETE http://localhost:5173/__gg/logs
310
+
311
+ # 2. Trigger the action (page load, button click, etc.)
312
+
313
+ # 3. Read the logs — SSR duplicates are removed by default
314
+ curl -s http://localhost:5173/__gg/logs
315
+
316
+ # Or query the file directly with jq
317
+ jq 'select(.lvl == "error")' .gg/logs-5173.jsonl
318
+ ```
319
+
320
+ **Each JSONL line contains:**
321
+
322
+ | Field | Description |
323
+ | -------- | ------------------------------------------------------------------------------------------- |
324
+ | `ns` | Namespace (file + function, e.g., `gg:routes/+page.svelte@click`) |
325
+ | `msg` | Formatted message string |
326
+ | `ts` | Unix epoch ms |
327
+ | `lvl` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` (omitted if debug) |
328
+ | `env` | `"client"` or `"server"` -- which runtime produced this entry |
329
+ | `origin` | `"tauri"` \| `"browser"` (client entries only) |
330
+ | `file` | Source file path |
331
+ | `line` | Source line number |
332
+ | `count` | Repeat count when consecutive entries share the same `ns`+`msg` (HTTP only, omitted when 1) |
333
+
334
+ **HTTP API:**
335
+
336
+ In SSR apps, component `gg()` calls fire on both server and client. The HTTP endpoint deduplicates by default: server entries are canonical; a client entry at the same `[ns, line]` is dropped if its `msg` is identical. Client-only call sites (e.g. `onMount`) and hydration mismatches (same `[ns, line]`, different `msg`) are always kept.
337
+
338
+ Consecutive repeated messages are collapsed by default (Chrome DevTools-style `count` field on the entry). Use `?raw` to disable collapsing and get one line per entry.
339
+
340
+ ```bash
341
+ # Default — deduplicated, repeated messages collapsed
342
+ curl -s http://localhost:5173/__gg/logs
343
+
344
+ # Prefer jq over grep for filtering NDJSON — cleaner field access
345
+ curl -s http://localhost:5173/__gg/logs | jq 'select(.msg | test("myFunction"))'
346
+ curl -s http://localhost:5173/__gg/logs | jq -r '.msg'
347
+ curl -s http://localhost:5173/__gg/logs | jq 'select(.lvl == "error")'
348
+
349
+ # All entries, both sides (raw file contents)
350
+ curl -s "http://localhost:5173/__gg/logs?all"
351
+
352
+ # Unrolled — one line per entry, no count collapsing
353
+ curl -s "http://localhost:5173/__gg/logs?raw"
354
+
355
+ # Only call sites where server and client produced different values (hydration mismatches)
356
+ curl -s "http://localhost:5173/__gg/logs?mismatch"
357
+
358
+ # Filter by namespace glob, environment, origin, or timestamp — all compose with dedup
359
+ curl -s "http://localhost:5173/__gg/logs?filter=api:*&env=server"
360
+ curl -s "http://localhost:5173/__gg/logs?origin=tauri"
361
+ curl -s "http://localhost:5173/__gg/logs?since=1741234567890"
362
+
363
+ # Status and endpoint list
364
+ curl -s http://localhost:5173/__gg/
365
+ ```
366
+
367
+ **Querying the file directly with `jq`:**
368
+
369
+ The JSONL file always contains all entries (both sides). Use `jq` when you need aggregation, field extraction, or queries the HTTP API doesn't cover:
370
+
371
+ ```bash
372
+ # Check the env split (useful in SSR apps)
373
+ jq -s 'group_by(.env) | map({env: .[0].env, count: length})' .gg/logs-5173.jsonl
374
+
375
+ # Errors only
376
+ jq 'select(.lvl == "error")' .gg/logs-5173.jsonl
377
+
378
+ # Entries from a specific file
379
+ jq 'select(.file | contains("+page.svelte"))' .gg/logs-5173.jsonl
380
+
381
+ # Messages with source location
382
+ jq -r '"\(.file):\(.line) \(.msg)"' .gg/logs-5173.jsonl
383
+
384
+ # Count entries by namespace
385
+ jq -s 'group_by(.ns) | map({ns: .[0].ns, count: length}) | sort_by(-.count)' .gg/logs-5173.jsonl
386
+ ```
387
+
388
+ The file is truncated on dev server restart. Use `DELETE /__gg/logs` to clear mid-session. The `origin` field distinguishes Tauri webview (`"tauri"`) from browser tab (`"browser"`) when both are open.
389
+
390
+ **Add to your project's `AGENTS.md`** -- see [Agent Instructions Template](specs/gg-agent-file-sink.md#agent-instructions-template-for-consuming-projects-agentsmd) in the spec for a copy-paste-ready block to add to consuming projects.
391
+
286
392
  ## Color Support (ANSI)
287
393
 
288
394
  Color your logs for better visual distinction using `fg()` (foreground/text) and `bg()` (background):
@@ -2,7 +2,7 @@
2
2
  * Browser-specific debug implementation.
3
3
  *
4
4
  * Output: console.debug with %c CSS color formatting.
5
- * Persistence: localStorage.debug
5
+ * Persistence: localStorage['gg-show'] + localStorage['gg-console']
6
6
  * Format (patched): +123ms namespace message
7
7
  */
8
8
  import { type DebugFactory } from './common.js';
@@ -2,7 +2,7 @@
2
2
  * Browser-specific debug implementation.
3
3
  *
4
4
  * Output: console.debug with %c CSS color formatting.
5
- * Persistence: localStorage.debug
5
+ * Persistence: localStorage['gg-show'] + localStorage['gg-console']
6
6
  * Format (patched): +123ms namespace message
7
7
  */
8
8
  import { setup, humanize } from './common.js';
@@ -59,12 +59,13 @@ function formatArgs(args) {
59
59
  }
60
60
  function save(namespaces) {
61
61
  try {
62
+ // Only persist non-empty patterns. Empty string means "console disabled"
63
+ // (gg-console=false), not a user-set filter — don't wipe gg-show in that case.
62
64
  if (namespaces) {
63
- localStorage.setItem('debug', namespaces);
64
- }
65
- else {
66
- localStorage.removeItem('debug');
65
+ localStorage.setItem('gg-show', namespaces);
67
66
  }
67
+ // Intentionally no removeItem: gg-show is the Show filter, persisted independently.
68
+ // Console-disabled state is tracked via gg-console, not by clearing gg-show.
68
69
  }
69
70
  catch {
70
71
  // localStorage may not be available
@@ -72,7 +73,14 @@ function save(namespaces) {
72
73
  }
73
74
  function load() {
74
75
  try {
75
- return localStorage.getItem('debug') || localStorage.getItem('DEBUG') || '';
76
+ // gg-console controls whether native console output is enabled at all.
77
+ // When it is 'false', disable all console output by returning '' (nothing enabled).
78
+ const consoleEnabled = localStorage.getItem('gg-console');
79
+ if (consoleEnabled === 'false')
80
+ return '';
81
+ // Use gg-show as the namespace filter for console output.
82
+ // Fall back to '*' (show all) so zero-config works out of the box.
83
+ return localStorage.getItem('gg-show') || '*';
76
84
  }
77
85
  catch {
78
86
  return '';
@@ -94,14 +94,16 @@ function log(...args) {
94
94
  }
95
95
  function save(namespaces) {
96
96
  if (namespaces) {
97
- process.env.DEBUG = namespaces;
97
+ process.env.GG_KEEP = namespaces;
98
98
  }
99
99
  else {
100
- delete process.env.DEBUG;
100
+ delete process.env.GG_KEEP;
101
101
  }
102
102
  }
103
103
  function load() {
104
- return process.env.DEBUG || '';
104
+ // GG_KEEP controls which namespaces are kept (and thus output to the server console).
105
+ // Fall back to '*' so gg works zero-config in dev without setting any env var.
106
+ return process.env.GG_KEEP || '*';
105
107
  }
106
108
  function init(instance) {
107
109
  // Each instance gets its own inspectOpts copy (for per-instance color override)
@@ -53,4 +53,8 @@ export declare class LogBuffer {
53
53
  * Get the maximum capacity
54
54
  */
55
55
  get capacity(): number;
56
+ /**
57
+ * Resize the buffer. Existing entries are preserved up to newCapacity (oldest dropped if shrinking).
58
+ */
59
+ resize(newCapacity: number): void;
56
60
  }
@@ -101,4 +101,20 @@ export class LogBuffer {
101
101
  get capacity() {
102
102
  return this.maxSize;
103
103
  }
104
+ /**
105
+ * Resize the buffer. Existing entries are preserved up to newCapacity (oldest dropped if shrinking).
106
+ */
107
+ resize(newCapacity) {
108
+ const entries = this.getEntries(); // oldest→newest, up to current count
109
+ this.maxSize = newCapacity;
110
+ this.buf = new Array(newCapacity);
111
+ this.head = 0;
112
+ this.count = 0;
113
+ this._totalPushed = 0;
114
+ // Re-push entries, keeping the most recent up to newCapacity
115
+ const start = entries.length > newCapacity ? entries.length - newCapacity : 0;
116
+ for (let i = start; i < entries.length; i++) {
117
+ this.push(entries[i]);
118
+ }
119
+ }
104
120
  }
@@ -52,6 +52,61 @@ export function shouldLoadEruda(options) {
52
52
  const prodTriggers = options.prod ?? ['url-param', 'gesture'];
53
53
  return checkProdTriggers(prodTriggers);
54
54
  }
55
+ /**
56
+ * Adjusts document.body padding-bottom to match the Eruda panel height so
57
+ * the page remains fully scrollable while the panel is open.
58
+ *
59
+ * Mirrors the TanStack Router devtools approach: inject padding when visible,
60
+ * removed when hidden. A ResizeObserver tracks panel resizes (e.g. the user
61
+ * drags it taller or shorter).
62
+ *
63
+ * Implementation notes:
64
+ * - Eruda uses shadow DOM by default, so #eruda.shadowRoot must be queried
65
+ * - The visible panel is .eruda-dev-tools inside .eruda-container
66
+ * - Eruda appends its DOM asynchronously, so we poll with rAF until it appears
67
+ */
68
+ function setupBodyPadding(
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ eruda, initiallyOpen) {
71
+ let attempts = 0;
72
+ function trySetup() {
73
+ const host = document.getElementById('eruda');
74
+ const root = host?.shadowRoot ?? host;
75
+ const container = root?.querySelector('.eruda-container');
76
+ const panel = container?.querySelector('.eruda-dev-tools');
77
+ if (!panel) {
78
+ if (++attempts < 60)
79
+ requestAnimationFrame(trySetup);
80
+ return;
81
+ }
82
+ let observer = null;
83
+ function applyPadding() {
84
+ const h = panel.offsetHeight;
85
+ document.body.style.paddingBottom = `${h}px`;
86
+ // Ensure the document is tall enough to scroll even on short pages.
87
+ document.documentElement.style.minHeight = `calc(100vh + ${h}px)`;
88
+ }
89
+ function clearPadding() {
90
+ document.body.style.paddingBottom = '';
91
+ document.documentElement.style.minHeight = '';
92
+ observer?.disconnect();
93
+ observer = null;
94
+ }
95
+ function startObserving() {
96
+ if (observer)
97
+ return;
98
+ observer = new ResizeObserver(applyPadding);
99
+ observer.observe(panel);
100
+ applyPadding();
101
+ }
102
+ const devTools = eruda.get();
103
+ devTools.on('show', startObserving);
104
+ devTools.on('hide', clearPadding);
105
+ if (initiallyOpen)
106
+ startObserving();
107
+ }
108
+ requestAnimationFrame(trySetup);
109
+ }
55
110
  /**
56
111
  * Dynamically imports and initializes Eruda
57
112
  */
@@ -92,6 +147,9 @@ export async function loadEruda(options) {
92
147
  if (options.open) {
93
148
  eruda.show();
94
149
  }
150
+ // Adjust body padding-bottom so the page remains fully scrollable while
151
+ // the panel is open — mirrors the TanStack Router devtools pattern.
152
+ setupBodyPadding(eruda, options.open ?? false);
95
153
  // Run diagnostics after Eruda is ready so they appear in Console tab
96
154
  await runGgDiagnostics();
97
155
  }
@@ -1,4 +1,4 @@
1
- import type { GgErudaOptions, CapturedEntry } from './types.js';
1
+ import type { GgErudaOptions, CapturedEntry, DroppedNamespaceInfo } from './types.js';
2
2
  /**
3
3
  * Licia jQuery-like wrapper used by Eruda
4
4
  */
@@ -19,11 +19,15 @@ interface LiciaElement {
19
19
  */
20
20
  export declare function createGgPlugin(options: GgErudaOptions, gg: {
21
21
  _onLog?: ((entry: CapturedEntry) => void) | null;
22
+ addLogListener?: (callback: (entry: CapturedEntry) => void) => void;
23
+ removeLogListener?: (callback: (entry: CapturedEntry) => void) => void;
22
24
  }): {
23
25
  name: string;
24
26
  init($container: LiciaElement): void;
25
27
  show(): void;
26
28
  hide(): void;
27
29
  destroy(): void;
30
+ /** Returns a read-only view of the dropped-namespace tracking map (Phase 2 data layer). */
31
+ getDroppedNamespaces(): ReadonlyMap<string, DroppedNamespaceInfo>;
28
32
  };
29
33
  export {};