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 +4 -1
- package/lib/github-source.js +105 -0
- package/package.json +1 -1
- package/public/app.js +178 -15
- package/public/index.html +42 -0
- package/public/style.css +11 -0
- package/server.js +21 -1
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
|
-
|
|
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
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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">✕</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',
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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)">✕</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
|
-
|
|
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
|
}
|