pi-inspect 0.1.0 → 0.2.0

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
@@ -5,7 +5,10 @@
5
5
 
6
6
  Introspection dashboard for the [pi coding agent](https://pi.dev) — see what's actually loaded into a session: tools, slash commands, skills, and the system prompt injected on init.
7
7
 
8
- ![pi-inspect demo](https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo.png)
8
+ <p align="center">
9
+ <img src="https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo.png" alt="pi-inspect demo" width="49%">
10
+ <img src="https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo-light.png" alt="pi-inspect demo light" width="49%">
11
+ </p>
9
12
 
10
13
  ## Installation
11
14
 
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const fsp = require('node:fs/promises');
4
+
5
+ const MAX_WALK = 8;
6
+ const cache = new Map(); // root -> { url, source } | null
7
+
8
+ function normalizeRepo(raw) {
9
+ if (!raw || typeof raw !== 'string') return null;
10
+ let s = raw.trim();
11
+ if (!s) return null;
12
+ s = s.replace(/^git\+/, '').replace(/\.git(\/|$)/, '$1').replace(/\/+$/, '');
13
+ // shorthand: github:owner/repo or owner/repo
14
+ let m = s.match(/^(?:github:)?([\w.-]+)\/([\w.-]+)$/i);
15
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
16
+ // ssh: git@github.com:owner/repo
17
+ m = s.match(/^git@github\.com:([\w.-]+)\/([\w.-]+)$/i);
18
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
19
+ // ssh url: ssh://git@github.com/owner/repo
20
+ m = s.match(/^ssh:\/\/git@github\.com\/([\w.-]+)\/([\w.-]+)$/i);
21
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
22
+ // https / git protocols
23
+ m = s.match(/^(?:https?|git):\/\/(?:[^@/]+@)?github\.com\/([\w.-]+)\/([\w.-]+)$/i);
24
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
25
+ return null;
26
+ }
27
+
28
+ async function tryPackageJson(dir) {
29
+ try {
30
+ const raw = await fsp.readFile(path.join(dir, 'package.json'), 'utf8');
31
+ const pkg = JSON.parse(raw);
32
+ const repo = pkg.repository;
33
+ let urlStr = null;
34
+ let subdir = null;
35
+ if (typeof repo === 'string') urlStr = repo;
36
+ else if (repo && typeof repo === 'object') {
37
+ urlStr = repo.url;
38
+ if (typeof repo.directory === 'string') subdir = repo.directory.replace(/^\/+|\/+$/g, '');
39
+ }
40
+ const base = normalizeRepo(urlStr);
41
+ if (!base) return null;
42
+ return { url: subdir ? `${base}/tree/HEAD/${subdir}` : base, source: 'package.json' };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async function tryGitConfig(dir) {
49
+ try {
50
+ const raw = await fsp.readFile(path.join(dir, '.git', 'config'), 'utf8');
51
+ // Find [remote "origin"] section then its url
52
+ const re = /\[remote\s+"origin"\]([\s\S]*?)(?=\n\[|\s*$)/;
53
+ const m = re.exec(raw);
54
+ if (!m) return null;
55
+ const urlM = /\burl\s*=\s*(.+)/.exec(m[1]);
56
+ if (!urlM) return null;
57
+ const url = normalizeRepo(urlM[1].trim());
58
+ if (!url) return null;
59
+ return { url, source: 'git' };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ async function resolveGithubUrl(root) {
66
+ if (!root || typeof root !== 'string') return null;
67
+ if (cache.has(root)) return cache.get(root);
68
+ const visited = [];
69
+ let dir = path.resolve(root);
70
+ let last = null;
71
+ for (let i = 0; i < MAX_WALK; i++) {
72
+ if (dir === last) break;
73
+ if (cache.has(dir)) {
74
+ const cached = cache.get(dir);
75
+ for (const v of visited) cache.set(v, cached);
76
+ cache.set(root, cached);
77
+ return cached;
78
+ }
79
+ visited.push(dir);
80
+ const [pj, gc] = await Promise.all([tryPackageJson(dir), tryGitConfig(dir)]);
81
+ const hit = pj || gc;
82
+ if (hit) {
83
+ for (const v of visited) cache.set(v, hit);
84
+ cache.set(root, hit);
85
+ return hit;
86
+ }
87
+ last = dir;
88
+ dir = path.dirname(dir);
89
+ }
90
+ for (const v of visited) cache.set(v, null);
91
+ cache.set(root, null);
92
+ return null;
93
+ }
94
+
95
+ async function resolveMany(roots) {
96
+ const unique = [...new Set(roots.filter((r) => typeof r === 'string' && r))];
97
+ const out = {};
98
+ await Promise.all(unique.map(async (r) => {
99
+ const hit = await resolveGithubUrl(r);
100
+ if (hit) out[r] = hit;
101
+ }));
102
+ return out;
103
+ }
104
+
105
+ module.exports = { resolveGithubUrl, resolveMany, normalizeRepo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inspect",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Introspection dashboard for the pi coding agent — tools, slash commands, skills, and the system prompt injected on init.",
5
5
  "keywords": [
6
6
  "pi-package",
package/public/app.js CHANGED
@@ -8,8 +8,15 @@ const state = {
8
8
  expanded: { context: true, tool: true, command: true, skill: true },
9
9
  selected: null,
10
10
  expandAll: true,
11
+ highlight: -1,
12
+ visibleRows: [],
11
13
  };
12
14
  const els = {};
15
+
16
+ function matchKey(e, ...keys) {
17
+ if (e.ctrlKey || e.altKey || e.metaKey) return false;
18
+ return keys.some((k) => e.key === k || e.code === k);
19
+ }
13
20
  //#endregion
14
21
 
15
22
  //#region UTIL
@@ -92,6 +99,13 @@ function inferPath(x) {
92
99
  return p;
93
100
  }
94
101
 
102
+ function githubUrlFor(it) {
103
+ const map = state.snapshot?.githubSources;
104
+ const root = it.raw?.sourceInfo?.baseDir;
105
+ if (!map || !root) return null;
106
+ return map[root]?.url || null;
107
+ }
108
+
95
109
  function inferSource(x) {
96
110
  const si = x?.sourceInfo;
97
111
  if (si) {
@@ -109,12 +123,14 @@ function buildItems() {
109
123
  if (!s) return [];
110
124
  const items = [];
111
125
  for (const t of s.tools ?? []) {
126
+ const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
112
127
  items.push({
113
128
  kind: 'tool',
114
129
  id: `tool:${t.name}`,
115
130
  name: t.name ?? '(tool)',
116
131
  source: inferSource(t),
117
- description: t.description ?? '',
132
+ description,
133
+ chars: (t.description ?? '').length,
118
134
  active: (s.activeTools ?? []).includes(t.name),
119
135
  path: inferPath(t),
120
136
  raw: t,
@@ -123,12 +139,14 @@ function buildItems() {
123
139
  for (const c of s.commands ?? []) {
124
140
  const name = c.name ?? c.command ?? '';
125
141
  const isSkill = name.startsWith('skill:');
142
+ const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
126
143
  items.push({
127
144
  kind: isSkill ? 'skill' : 'command',
128
145
  id: `${isSkill ? 'skill' : 'command'}:${name}`,
129
146
  name: `/${name}`,
130
147
  source: inferSource(c),
131
- description: c.description ?? '',
148
+ description,
149
+ chars: (c.description ?? '').length,
132
150
  path: inferPath(c),
133
151
  raw: c,
134
152
  });
@@ -141,6 +159,7 @@ function buildItems() {
141
159
  name: part.name,
142
160
  source: `${part.text.length} chars`,
143
161
  description: part.text.slice(0, 240).replace(/\s+/g, ' '),
162
+ chars: part.text.length,
144
163
  path: part.path ?? null,
145
164
  raw: { systemPrompt: part.text, path: part.path ?? null },
146
165
  });
@@ -149,6 +168,15 @@ function buildItems() {
149
168
  return items;
150
169
  }
151
170
 
171
+ function fmtChars(n) {
172
+ if (n == null) return '';
173
+ if (n < 1000) return `${n} chars`;
174
+ return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k chars`;
175
+ }
176
+ function sumChars(list) {
177
+ return list.reduce((a, b) => a + (b.chars || 0), 0);
178
+ }
179
+
152
180
  function filterItems(items) {
153
181
  const q = state.search.trim().toLowerCase();
154
182
  return items.filter((it) => {
@@ -156,7 +184,7 @@ function filterItems(items) {
156
184
  if (!q) return true;
157
185
  return (
158
186
  it.name.toLowerCase().includes(q) ||
159
- (it.description ?? '').toLowerCase().includes(q)
187
+ (it.source ?? '').toLowerCase().includes(q)
160
188
  );
161
189
  });
162
190
  }
@@ -280,17 +308,20 @@ function renderTree() {
280
308
  for (const it of items) groups.get(it.kind).push(it);
281
309
 
282
310
  const html = [];
311
+ const rows = [];
283
312
  for (const kind of KIND_ORDER) {
284
313
  const list = groups.get(kind);
285
314
  if (!list.length) continue;
286
315
  const expanded = state.expanded[kind];
316
+ rows.push({ type: 'group', key: kind });
317
+ const groupChars = sumChars(list);
287
318
  html.push(`
288
319
  <div class="tree-row marketplace-row" data-group="${kind}">
289
320
  <div class="tree-chevron ${expanded ? 'expanded' : ''}">${chevronSvg()}</div>
290
321
  <div class="tree-icon">${iconFor(kind)}</div>
291
322
  <div class="tree-label"><span class="mkt-name">${esc(KIND_LABEL[kind])}</span></div>
292
323
  <div class="spacer"></div>
293
- <div class="tree-meta">${list.length}</div>
324
+ <div class="tree-meta">${list.length} · ${fmtChars(groupChars)}</div>
294
325
  </div>
295
326
  `);
296
327
  if (expanded) {
@@ -303,9 +334,11 @@ function renderTree() {
303
334
  const useSubgroups = bySource.size > 1 && kind !== 'context';
304
335
  const sources = useSubgroups
305
336
  ? [...bySource.keys()].sort((a, b) => {
306
- if (a === 'builtin') return -1;
307
- if (b === 'builtin') return 1;
308
- return a.localeCompare(b);
337
+ const rank = (s) => {
338
+ const i = ['auto', 'builtin'].indexOf(s);
339
+ return i === -1 ? Infinity : i;
340
+ };
341
+ return (rank(a) - rank(b)) || a.localeCompare(b);
309
342
  })
310
343
  : ['__all__'];
311
344
  if (!useSubgroups) bySource.set('__all__', list);
@@ -314,13 +347,15 @@ function renderTree() {
314
347
  const subKey = `${kind}::${src}`;
315
348
  const subExpanded = state.expanded[subKey] !== false;
316
349
  if (useSubgroups) {
350
+ rows.push({ type: 'subgroup', key: subKey });
351
+ const subChars = sumChars(sublist);
317
352
  html.push(`
318
353
  <div class="tree-row marketplace-row tree-subgroup" data-subgroup="${esc(subKey)}" style="padding-left:24px">
319
354
  <div class="tree-chevron ${subExpanded ? 'expanded' : ''}">${chevronSvg()}</div>
320
355
  <div class="tree-icon">${packageSvg()}</div>
321
356
  <div class="tree-label"><span class="mkt-name">${esc(src)}</span></div>
322
357
  <div class="spacer"></div>
323
- <div class="tree-meta">${sublist.length}</div>
358
+ <div class="tree-meta">${sublist.length} · ${fmtChars(subChars)}</div>
324
359
  </div>
325
360
  `);
326
361
  }
@@ -328,11 +363,15 @@ function renderTree() {
328
363
  for (const it of sublist) {
329
364
  const selected = state.selected === it.id ? 'selected' : '';
330
365
  const pad = useSubgroups ? 48 : 32;
366
+ rows.push({ type: 'item', key: it.id });
367
+ const descHtml = it.description
368
+ ? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
369
+ : '<div class="spacer"></div>';
331
370
  html.push(`
332
371
  <div class="tree-row ${selected}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
333
372
  <div class="tree-icon">${iconFor(it.kind)}</div>
334
373
  <div class="tree-label">${esc(it.name)}</div>
335
- <div class="spacer"></div>
374
+ ${descHtml}
336
375
  <div class="tree-meta">${esc(it.source)}</div>
337
376
  </div>
338
377
  `);
@@ -342,6 +381,12 @@ function renderTree() {
342
381
  }
343
382
  }
344
383
  root.innerHTML = html.join('');
384
+ state.visibleRows = rows;
385
+ if (state.highlight >= rows.length) state.highlight = rows.length - 1;
386
+ if (state.highlight >= 0) {
387
+ const el = root.children[state.highlight];
388
+ if (el) el.classList.add('focused');
389
+ }
345
390
 
346
391
  root.querySelectorAll('.tree-row[data-group]').forEach((el) => {
347
392
  el.addEventListener('click', () => {
@@ -360,6 +405,7 @@ function renderTree() {
360
405
  root.querySelectorAll('.tree-row[data-item]').forEach((el) => {
361
406
  el.addEventListener('click', () => {
362
407
  state.selected = el.dataset.item;
408
+ state.highlight = -1;
363
409
  renderTree();
364
410
  renderDetail();
365
411
  });
@@ -424,12 +470,14 @@ function renderDetail() {
424
470
  `);
425
471
  }
426
472
 
473
+ const ghUrl = githubUrlFor(it);
427
474
  panel.innerHTML = `
428
475
  <div class="detail-header">
429
476
  <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
430
477
  <div class="detail-header-actions">
431
478
  ${it.path ? `<button class="detail-action" id="openEditorBtn" title="Open in $EDITOR"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>` : ''}
432
479
  ${it.path ? `<button class="detail-action" id="copyPathBtn" title="Copy path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>` : ''}
480
+ ${ghUrl ? `<button class="detail-action" id="openGithubBtn" title="Open on GitHub"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.27-1.68-1.27-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.96 10.96 0 015.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56C20.21 21.38 23.5 17.07 23.5 12 23.5 5.65 18.35.5 12 .5z"/></svg></button>` : ''}
433
481
  <button class="detail-close" id="detailCloseBtn" title="Close">&#10005;</button>
434
482
  </div>
435
483
  </div>
@@ -462,6 +510,10 @@ function renderDetail() {
462
510
  try { await navigator.clipboard.writeText(it.path); toast('Path copied'); }
463
511
  catch { toast('Copy failed'); }
464
512
  });
513
+ const ghBtn = $('openGithubBtn');
514
+ if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
515
+ window.open(ghUrl, '_blank', 'noopener');
516
+ });
465
517
  }
466
518
  //#endregion
467
519
 
@@ -540,6 +592,12 @@ function bindEvents() {
540
592
  $('expandToggle').addEventListener('click', () => {
541
593
  state.expandAll = !state.expandAll;
542
594
  for (const k of KIND_ORDER) state.expanded[k] = state.expandAll;
595
+ const subKeys = new Set(Object.keys(state.expanded).filter((k) => k.includes('::')));
596
+ for (const it of buildItems()) subKeys.add(`${it.kind}::${it.source || '(unknown)'}`);
597
+ for (const key of subKeys) {
598
+ if (state.expandAll) delete state.expanded[key];
599
+ else state.expanded[key] = false;
600
+ }
543
601
  $('expandToggle').textContent = state.expandAll ? 'Collapse all' : 'Expand all';
544
602
  renderTree();
545
603
  });
@@ -551,15 +609,120 @@ function bindEvents() {
551
609
  renderDetail();
552
610
  });
553
611
 
554
- window.addEventListener('keydown', (e) => {
555
- if (e.target?.tagName === 'INPUT' || e.target?.tagName === 'SELECT') return;
556
- if (e.key === '/') { e.preventDefault(); $('searchInput').focus(); }
557
- else if (e.key === 'r' || e.key === 'R') $('refreshBtn').click();
558
- else if (e.key === 't' || e.key === 'T') $('themeBtn').click();
559
- else if (e.key === 'Escape') { state.selected = null; renderTree(); renderDetail(); }
612
+ window.addEventListener('keydown', handleKeydown);
613
+
614
+ document.addEventListener('selectionchange', () => {
615
+ const sel = document.getSelection();
616
+ if (!sel || sel.isCollapsed) return;
617
+ const node = sel.anchorNode;
618
+ const host = node?.nodeType === 1 ? node : node?.parentElement;
619
+ const row = host?.closest?.('.tree-row[data-item]');
620
+ if (!row) return;
621
+ const id = row.dataset.item;
622
+ const root = $('treeContainer');
623
+ const idx = Array.prototype.indexOf.call(root.children, row);
624
+ if (idx >= 0) state.highlight = idx;
625
+ if (state.selected !== id) {
626
+ state.selected = id;
627
+ renderTree();
628
+ renderDetail();
629
+ }
630
+ row.scrollIntoView({ block: 'nearest' });
631
+ });
632
+
633
+ $('shortcutsBtn').addEventListener('click', showHelpModal);
634
+ $('helpCloseBtn').addEventListener('click', hideHelpModal);
635
+ $('helpModal').addEventListener('click', (e) => {
636
+ if (e.target === $('helpModal')) hideHelpModal();
560
637
  });
561
638
  }
562
639
 
640
+ function moveHighlight(delta) {
641
+ const rows = state.visibleRows;
642
+ if (!rows.length) return;
643
+ const prev = state.highlight;
644
+ let idx = prev;
645
+ if (idx < 0) {
646
+ const selIdx = state.selected ? rows.findIndex((r) => r.type === 'item' && r.key === state.selected) : -1;
647
+ if (selIdx >= 0) idx = Math.max(0, Math.min(rows.length - 1, selIdx + delta));
648
+ else idx = delta > 0 ? 0 : rows.length - 1;
649
+ } else idx = Math.max(0, Math.min(rows.length - 1, idx + delta));
650
+ if (idx === prev) return;
651
+ state.highlight = idx;
652
+ const root = $('treeContainer');
653
+ if (prev >= 0) root.children[prev]?.classList.remove('focused');
654
+ const el = root.children[idx];
655
+ if (el) {
656
+ el.classList.add('focused');
657
+ el.scrollIntoView({ block: 'nearest' });
658
+ }
659
+ }
660
+
661
+ function activateHighlight() {
662
+ const r = state.visibleRows[state.highlight];
663
+ if (!r) return;
664
+ const root = $('treeContainer');
665
+ const el = root.children[state.highlight];
666
+ if (el) el.click();
667
+ }
668
+
669
+ function expandAtHighlight(open) {
670
+ const r = state.visibleRows[state.highlight];
671
+ if (!r) return;
672
+ if (r.type === 'group') {
673
+ state.expanded[r.key] = open;
674
+ renderTree();
675
+ } else if (r.type === 'subgroup') {
676
+ if (open) delete state.expanded[r.key];
677
+ else state.expanded[r.key] = false;
678
+ renderTree();
679
+ } else if (r.type === 'item' && !open) {
680
+ for (let i = state.highlight - 1; i >= 0; i--) {
681
+ if (state.visibleRows[i].type === 'group' || state.visibleRows[i].type === 'subgroup') {
682
+ state.highlight = i;
683
+ renderTree();
684
+ break;
685
+ }
686
+ }
687
+ }
688
+ }
689
+
690
+ function showHelpModal() { $('helpModal').classList.add('open'); }
691
+ function hideHelpModal() { $('helpModal').classList.remove('open'); }
692
+ function isHelpOpen() { return $('helpModal')?.classList.contains('open'); }
693
+
694
+ function handleKeydown(e) {
695
+ if (isHelpOpen()) {
696
+ if (e.key === 'Escape' || e.key === '?') {
697
+ e.preventDefault();
698
+ hideHelpModal();
699
+ }
700
+ return;
701
+ }
702
+ const tag = e.target?.tagName;
703
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
704
+ if (e.key === 'Escape') {
705
+ e.target.blur();
706
+ e.preventDefault();
707
+ }
708
+ return;
709
+ }
710
+ if (e.key === '?') { e.preventDefault(); showHelpModal(); return; }
711
+ if (matchKey(e, '/')) { e.preventDefault(); $('searchInput').focus(); return; }
712
+ if (matchKey(e, 'f', 'F')) { e.preventDefault(); $('kindFilter').focus(); return; }
713
+ if (matchKey(e, 'r', 'R')) { e.preventDefault(); $('refreshBtn').click(); return; }
714
+ if (matchKey(e, 't', 'T')) { e.preventDefault(); $('themeBtn').click(); return; }
715
+ if (matchKey(e, 'e', 'E')) { e.preventDefault(); $('expandToggle').click(); return; }
716
+ if (matchKey(e, 'j', 'ArrowDown')) { e.preventDefault(); moveHighlight(1); return; }
717
+ if (matchKey(e, 'k', 'ArrowUp')) { e.preventDefault(); moveHighlight(-1); return; }
718
+ if (matchKey(e, 'l', 'ArrowRight')) { e.preventDefault(); expandAtHighlight(true); return; }
719
+ if (matchKey(e, 'h', 'ArrowLeft')) { e.preventDefault(); expandAtHighlight(false); return; }
720
+ if (matchKey(e, 'Enter', ' ', 'Space')) { e.preventDefault(); activateHighlight(); return; }
721
+ if (e.key === 'Escape') {
722
+ if (state.selected) { state.selected = null; renderTree(); renderDetail(); }
723
+ }
724
+ }
725
+
563
726
  function bindSse() {
564
727
  const es = new EventSource('/api/events');
565
728
  es.addEventListener('snapshot', async () => {
package/public/index.html CHANGED
@@ -45,6 +45,13 @@
45
45
  <button class="topbar-btn" id="themeBtn" title="Toggle theme">
46
46
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
47
47
  </button>
48
+ <button class="topbar-btn" id="shortcutsBtn" title="Keyboard shortcuts (?)">
49
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
50
+ <kbd style="font-size:10px">?</kbd>
51
+ </button>
52
+ <a class="topbar-btn" href="https://github.com/NikiforovAll/pi-inspect" target="_blank" rel="noopener" title="GitHub">
53
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
54
+ </a>
48
55
  </div>
49
56
 
50
57
  <div class="main-layout">
@@ -65,6 +72,41 @@
65
72
 
66
73
  <div class="toast-container" id="toast"></div>
67
74
 
75
+ <div class="modal-overlay" id="helpModal" aria-hidden="true">
76
+ <div class="modal help-modal" role="dialog" aria-label="Keyboard shortcuts">
77
+ <div class="modal-header">
78
+ <h3>Keyboard Shortcuts</h3>
79
+ <button class="modal-close" id="helpCloseBtn" title="Close (Esc)">&#10005;</button>
80
+ </div>
81
+ <div class="modal-body">
82
+ <div class="help-sections">
83
+ <div class="help-section">
84
+ <h4>Global</h4>
85
+ <table>
86
+ <tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
87
+ <tr><td><kbd>/</kbd></td><td>Focus search</td></tr>
88
+ <tr><td><kbd>f</kbd></td><td>Focus kind filter</td></tr>
89
+ <tr><td><kbd>r</kbd></td><td>Refresh</td></tr>
90
+ <tr><td><kbd>t</kbd></td><td>Toggle theme</td></tr>
91
+ <tr><td><kbd>e</kbd></td><td>Expand / collapse all</td></tr>
92
+ <tr><td><kbd>Esc</kbd></td><td>Close modal / clear selection / blur input</td></tr>
93
+ </table>
94
+ </div>
95
+ <div class="help-section">
96
+ <h4>Navigation</h4>
97
+ <table>
98
+ <tr><td><kbd>j</kbd> / <kbd>↓</kbd></td><td>Next row</td></tr>
99
+ <tr><td><kbd>k</kbd> / <kbd>↑</kbd></td><td>Previous row</td></tr>
100
+ <tr><td><kbd>l</kbd> / <kbd>→</kbd></td><td>Expand group</td></tr>
101
+ <tr><td><kbd>h</kbd> / <kbd>←</kbd></td><td>Collapse group / go to parent</td></tr>
102
+ <tr><td><kbd>Enter</kbd> / <kbd>Space</kbd></td><td>Select / toggle</td></tr>
103
+ </table>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+
68
110
  <script src="app.js"></script>
69
111
  <script>
70
112
  if ('serviceWorker' in navigator) {
package/public/style.css CHANGED
@@ -438,6 +438,17 @@ body {
438
438
  flex-shrink: 0;
439
439
  white-space: nowrap;
440
440
  }
441
+ .tree-desc {
442
+ font-size: 11px;
443
+ color: var(--text-muted);
444
+ margin-left: 12px;
445
+ white-space: nowrap;
446
+ overflow: hidden;
447
+ text-overflow: ellipsis;
448
+ flex: 9 1 0;
449
+ min-width: 0;
450
+ opacity: 0.75;
451
+ }
441
452
 
442
453
  /* === SCOPE TOGGLE BOXES === */
443
454
 
package/server.js CHANGED
@@ -7,6 +7,7 @@ const os = require('node:os');
7
7
  const chokidar = require('chokidar');
8
8
  const open = require('open').default || require('open');
9
9
  const snapshots = require('./lib/snapshot');
10
+ const githubSource = require('./lib/github-source');
10
11
  const pkg = require('./package.json');
11
12
 
12
13
  const PORT = Number(process.env.PORT) || 5462;
@@ -39,12 +40,31 @@ app.get('/api/sessions', async (_req, res) => {
39
40
  }
40
41
  });
41
42
 
43
+ const githubMemo = new Map(); // sessionId -> Record<root, {url, source}>
44
+
45
+ function collectSourceRoots(snap) {
46
+ const roots = new Set();
47
+ const add = (x) => {
48
+ const baseDir = x?.sourceInfo?.baseDir;
49
+ if (typeof baseDir === 'string' && baseDir) roots.add(baseDir);
50
+ };
51
+ for (const t of snap.tools ?? []) add(t);
52
+ for (const c of snap.commands ?? []) add(c);
53
+ return [...roots];
54
+ }
55
+
42
56
  app.get('/api/introspect', async (req, res) => {
43
57
  try {
44
58
  const sid = req.query.session ? String(req.query.session) : null;
45
59
  const snap = sid ? await snapshots.readSnapshot(sid) : await snapshots.readLatestSnapshot();
46
60
  if (!snap) return res.status(404).json({ error: 'no snapshot found', sessionId: sid });
47
- res.json(snap);
61
+ const memoKey = snap.sessionId;
62
+ let githubSources = memoKey ? githubMemo.get(memoKey) : null;
63
+ if (!githubSources) {
64
+ githubSources = await githubSource.resolveMany(collectSourceRoots(snap));
65
+ if (memoKey) githubMemo.set(memoKey, githubSources);
66
+ }
67
+ res.json({ ...snap, githubSources });
48
68
  } catch (e) {
49
69
  res.status(500).json({ error: e.message });
50
70
  }