pi-inspect 0.5.0 → 0.6.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/CHANGELOG.md +28 -0
- package/README.md +58 -4
- package/extensions/inspect.ts +54 -52
- package/lib/agent-dir.js +18 -0
- package/lib/app.js +144 -0
- package/lib/args.js +30 -0
- package/lib/editor.js +26 -0
- package/lib/snapshot.js +57 -79
- package/lib/watch.js +29 -0
- package/package.json +9 -8
- package/public/app.js +646 -622
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +94 -78
- package/public/style.css +416 -1245
- package/public/sw.js +13 -21
- package/server.js +27 -198
- package/themes/pi-dark.json +0 -21
- package/themes/pi-light.json +0 -21
package/public/app.js
CHANGED
|
@@ -5,19 +5,13 @@ const state = {
|
|
|
5
5
|
snapshot: null,
|
|
6
6
|
search: '',
|
|
7
7
|
kind: 'all',
|
|
8
|
-
|
|
8
|
+
collapsed: new Set(),
|
|
9
9
|
selected: null,
|
|
10
10
|
expandAll: true,
|
|
11
11
|
highlight: -1,
|
|
12
12
|
visibleRows: [],
|
|
13
13
|
staticMode: false,
|
|
14
14
|
};
|
|
15
|
-
const els = {};
|
|
16
|
-
|
|
17
|
-
function matchKey(e, ...keys) {
|
|
18
|
-
if (e.ctrlKey || e.altKey || e.metaKey) return false;
|
|
19
|
-
return keys.some((k) => e.key === k || e.code === k);
|
|
20
|
-
}
|
|
21
15
|
//#endregion
|
|
22
16
|
|
|
23
17
|
//#region UTIL
|
|
@@ -42,12 +36,26 @@ function highlightJson(value) {
|
|
|
42
36
|
);
|
|
43
37
|
}
|
|
44
38
|
|
|
39
|
+
function highlightMarkdown(md) {
|
|
40
|
+
let s = esc(md);
|
|
41
|
+
s = s.replace(/^(#{1,6}\s.*)$/gm, '<span class="md-h">$1</span>');
|
|
42
|
+
s = s.replace(/(```[\s\S]*?```)/g, '<span class="md-code">$1</span>');
|
|
43
|
+
s = s.replace(/(`[^`\n]+`)/g, '<span class="md-icode">$1</span>');
|
|
44
|
+
s = s.replace(/(\*\*[^*\n]+\*\*)/g, '<span class="md-bold">$1</span>');
|
|
45
|
+
s = s.replace(/^(\s*[-*+]\s+)/gm, '<span class="md-bullet">$1</span>');
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
function basename(p) {
|
|
46
50
|
if (!p) return '';
|
|
47
51
|
const parts = String(p).split(/[\\/]/).filter(Boolean);
|
|
48
52
|
return parts[parts.length - 1] ?? '';
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function shortId(id) {
|
|
56
|
+
return id.length > 12 ? `${id.slice(0, 8)}…` : id;
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
function getUrlSession() {
|
|
52
60
|
return new URLSearchParams(location.search).get('session');
|
|
53
61
|
}
|
|
@@ -59,14 +67,21 @@ function setUrlSession(id, replace = false) {
|
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
function toast(msg, kind = 'info') {
|
|
62
|
-
const c = $('toast');
|
|
63
|
-
if (!c) return;
|
|
64
70
|
const el = document.createElement('div');
|
|
65
71
|
el.className = `toast toast-${kind}`;
|
|
66
72
|
el.textContent = msg;
|
|
67
|
-
|
|
73
|
+
$('toast').appendChild(el);
|
|
68
74
|
setTimeout(() => el.remove(), 2500);
|
|
69
75
|
}
|
|
76
|
+
|
|
77
|
+
function fmtChars(n) {
|
|
78
|
+
if (n == null) return '';
|
|
79
|
+
if (n < 1000) return `${n} chars`;
|
|
80
|
+
return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k chars`;
|
|
81
|
+
}
|
|
82
|
+
function sumChars(list) {
|
|
83
|
+
return list.reduce((a, b) => a + (b.chars || 0), 0);
|
|
84
|
+
}
|
|
70
85
|
//#endregion
|
|
71
86
|
|
|
72
87
|
//#region FETCH
|
|
@@ -76,6 +91,20 @@ async function fetchJson(path) {
|
|
|
76
91
|
return r.json();
|
|
77
92
|
}
|
|
78
93
|
|
|
94
|
+
async function postJson(path, body) {
|
|
95
|
+
const r = await fetch(path, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
});
|
|
100
|
+
if (!(r.headers.get('content-type') || '').includes('application/json')) {
|
|
101
|
+
throw new Error(`server returned ${r.status}; restart the pi-inspect server`);
|
|
102
|
+
}
|
|
103
|
+
const j = await r.json();
|
|
104
|
+
if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
|
|
105
|
+
return j;
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
async function loadSessions() {
|
|
80
109
|
const data = await fetchJson('/api/sessions');
|
|
81
110
|
state.sessions = data.sessions ?? [];
|
|
@@ -93,28 +122,33 @@ async function loadSnapshot(sessionId) {
|
|
|
93
122
|
}
|
|
94
123
|
//#endregion
|
|
95
124
|
|
|
125
|
+
//#region MODEL
|
|
126
|
+
const KIND_ORDER = ['context', 'tool', 'command', 'prompt', 'skill'];
|
|
127
|
+
const CHIP_KINDS = ['all', ...KIND_ORDER];
|
|
128
|
+
const KIND_LABEL = { all: 'All', context: 'Context', tool: 'Tools', command: 'Commands', prompt: 'Prompts', skill: 'Skills' };
|
|
129
|
+
const SOURCE_RANK = { user: 0, project: 1, auto: 2, builtin: 3 };
|
|
130
|
+
|
|
131
|
+
// Same rule as sourceLabel() in extensions/inspect.ts, so active and disabled items of one package group together.
|
|
132
|
+
function packageName(source) {
|
|
133
|
+
if (source.startsWith('npm:')) return source.slice(4);
|
|
134
|
+
const norm = source.replace(/\\/g, '/').replace(/\/$/, '');
|
|
135
|
+
return norm.split('/').pop() || norm;
|
|
136
|
+
}
|
|
137
|
+
|
|
96
138
|
function inferPath(x) {
|
|
97
139
|
const p = x?.sourceInfo?.path;
|
|
98
140
|
if (!p || typeof p !== 'string') return null;
|
|
99
|
-
if (/^<.*>$/.test(p)) return null;
|
|
141
|
+
if (/^<.*>$/.test(p) || /^[a-z][\w-]+:/i.test(p)) return null;
|
|
100
142
|
return p;
|
|
101
143
|
}
|
|
102
144
|
|
|
103
|
-
function githubUrlFor(it) {
|
|
104
|
-
const map = state.snapshot?.githubSources;
|
|
105
|
-
const root = it.raw?.sourceInfo?.baseDir;
|
|
106
|
-
if (!map || !root) return null;
|
|
107
|
-
return map[root]?.url || null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
145
|
function inferSource(x) {
|
|
111
146
|
const si = x?.sourceInfo;
|
|
112
147
|
if (si) {
|
|
113
148
|
if (si.label) return si.label;
|
|
114
149
|
if (si.source) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return src;
|
|
150
|
+
if (si.source === 'auto' && si.scope) return si.scope;
|
|
151
|
+
return packageName(si.source);
|
|
118
152
|
}
|
|
119
153
|
if (si.origin) return si.origin;
|
|
120
154
|
if (si.kind) return si.kind;
|
|
@@ -122,14 +156,29 @@ function inferSource(x) {
|
|
|
122
156
|
return x?.source ?? 'builtin';
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
|
|
159
|
+
function githubUrlFor(it) {
|
|
160
|
+
const map = state.snapshot?.githubSources;
|
|
161
|
+
const root = it.raw?.sourceInfo?.baseDir;
|
|
162
|
+
if (!map || !root) return null;
|
|
163
|
+
return map[root]?.url || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
126
166
|
function normPath(p) {
|
|
127
167
|
if (!p || typeof p !== 'string') return null;
|
|
128
168
|
return p.replace(/\\/g, '/').toLowerCase();
|
|
129
169
|
}
|
|
130
170
|
|
|
131
|
-
|
|
132
|
-
|
|
171
|
+
const oneLine = (s) => (s ?? '').replace(/\s+/g, ' ').trim();
|
|
172
|
+
const textInfo = (text) => ({ description: oneLine(text), chars: (text ?? '').length });
|
|
173
|
+
const subgroupKey = (kind, source) => `${kind}::${source || '(unknown)'}`;
|
|
174
|
+
|
|
175
|
+
let itemsCache = { snapshot: null, items: [] };
|
|
176
|
+
function getItems() {
|
|
177
|
+
if (itemsCache.snapshot !== state.snapshot) itemsCache = { snapshot: state.snapshot, items: buildItems(state.snapshot) };
|
|
178
|
+
return itemsCache.items;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildItems(s) {
|
|
133
182
|
if (!s) return [];
|
|
134
183
|
const items = [];
|
|
135
184
|
const activeSet = new Set(s.activeTools ?? []);
|
|
@@ -141,7 +190,6 @@ function buildItems() {
|
|
|
141
190
|
(s.disabledItems ?? []).map((d) => normPath(d.path)).filter(Boolean),
|
|
142
191
|
);
|
|
143
192
|
for (const t of s.tools ?? []) {
|
|
144
|
-
const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
|
|
145
193
|
const id = `tool:${t.name}`;
|
|
146
194
|
activeIds.add(id);
|
|
147
195
|
items.push({
|
|
@@ -149,8 +197,7 @@ function buildItems() {
|
|
|
149
197
|
id,
|
|
150
198
|
name: t.name ?? '(tool)',
|
|
151
199
|
source: inferSource(t),
|
|
152
|
-
description,
|
|
153
|
-
chars: (t.description ?? '').length,
|
|
200
|
+
...textInfo(t.description),
|
|
154
201
|
active: activeSet.has(t.name),
|
|
155
202
|
path: inferPath(t),
|
|
156
203
|
raw: t,
|
|
@@ -161,55 +208,35 @@ function buildItems() {
|
|
|
161
208
|
if (cp && disabledPaths.has(cp)) continue;
|
|
162
209
|
const name = c.name ?? c.command ?? '';
|
|
163
210
|
const isSkill = name.startsWith('skill:');
|
|
164
|
-
const src = inferSource(c);
|
|
165
211
|
const isPrompt = !isSkill && c.source === 'prompt';
|
|
166
212
|
const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
|
|
167
|
-
const
|
|
168
|
-
const idName = isSkill ? name.slice('skill:'.length) : name;
|
|
169
|
-
const id = `${kind}:${idName}`;
|
|
213
|
+
const id = `${kind}:${isSkill ? name.slice('skill:'.length) : name}`;
|
|
170
214
|
activeIds.add(id);
|
|
171
215
|
items.push({
|
|
172
216
|
kind,
|
|
173
217
|
id,
|
|
174
218
|
name: `/${name}`,
|
|
175
|
-
source:
|
|
176
|
-
description,
|
|
177
|
-
chars: (c.description ?? '').length,
|
|
219
|
+
source: inferSource(c),
|
|
220
|
+
...textInfo(c.description),
|
|
178
221
|
path: inferPath(c),
|
|
179
222
|
raw: c,
|
|
180
223
|
});
|
|
181
224
|
}
|
|
182
|
-
for (const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
for (const d of s.pendingItems ?? []) {
|
|
199
|
-
const id = `${d.kind}:${d.name}`;
|
|
200
|
-
if (activeIds.has(id)) continue;
|
|
201
|
-
const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
|
|
202
|
-
items.push({
|
|
203
|
-
kind: d.kind,
|
|
204
|
-
id,
|
|
205
|
-
name: d.displayName ?? d.name,
|
|
206
|
-
source: d.source ?? '(package)',
|
|
207
|
-
description,
|
|
208
|
-
chars: (d.description ?? '').length,
|
|
209
|
-
pending: true,
|
|
210
|
-
path: d.path ?? null,
|
|
211
|
-
raw: d,
|
|
212
|
-
});
|
|
225
|
+
for (const [list, flag] of [[s.disabledItems, 'disabled'], [s.pendingItems, 'pending']]) {
|
|
226
|
+
for (const d of list ?? []) {
|
|
227
|
+
const id = `${d.kind}:${d.name}`;
|
|
228
|
+
if (activeIds.has(id)) continue;
|
|
229
|
+
items.push({
|
|
230
|
+
kind: d.kind,
|
|
231
|
+
id,
|
|
232
|
+
name: d.displayName ?? d.name,
|
|
233
|
+
source: d.source ?? '(package)',
|
|
234
|
+
...textInfo(d.description),
|
|
235
|
+
[flag]: true,
|
|
236
|
+
path: d.path ?? null,
|
|
237
|
+
raw: d,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
213
240
|
}
|
|
214
241
|
if (s.systemPrompt) {
|
|
215
242
|
for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
|
|
@@ -217,8 +244,7 @@ function buildItems() {
|
|
|
217
244
|
kind: 'context',
|
|
218
245
|
id: `context:${part.id}`,
|
|
219
246
|
name: part.name,
|
|
220
|
-
|
|
221
|
-
description: part.text.slice(0, 240).replace(/\s+/g, ' '),
|
|
247
|
+
description: oneLine(part.text.slice(0, 240)),
|
|
222
248
|
chars: part.text.length,
|
|
223
249
|
path: part.path ?? null,
|
|
224
250
|
raw: { systemPrompt: part.text, path: part.path ?? null },
|
|
@@ -228,61 +254,6 @@ function buildItems() {
|
|
|
228
254
|
return items;
|
|
229
255
|
}
|
|
230
256
|
|
|
231
|
-
function fmtChars(n) {
|
|
232
|
-
if (n == null) return '';
|
|
233
|
-
if (n < 1000) return `${n} chars`;
|
|
234
|
-
return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k chars`;
|
|
235
|
-
}
|
|
236
|
-
function sumChars(list) {
|
|
237
|
-
return list.reduce((a, b) => a + (b.chars || 0), 0);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function filterItems(items) {
|
|
241
|
-
const q = state.search.trim().toLowerCase();
|
|
242
|
-
return items.filter((it) => {
|
|
243
|
-
if (state.kind !== 'all' && it.kind !== state.kind) return false;
|
|
244
|
-
if (!q) return true;
|
|
245
|
-
return (
|
|
246
|
-
it.name.toLowerCase().includes(q) ||
|
|
247
|
-
(it.source ?? '').toLowerCase().includes(q)
|
|
248
|
-
);
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const KIND_ORDER = ['context', 'tool', 'command', 'prompt', 'skill'];
|
|
253
|
-
const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', prompt: 'Prompts', skill: 'Skills' };
|
|
254
|
-
const SOURCE_RANK = { user: 0, project: 1, auto: 2, builtin: 3 };
|
|
255
|
-
//#endregion
|
|
256
|
-
|
|
257
|
-
//#region ICONS
|
|
258
|
-
function iconFor(kind) {
|
|
259
|
-
if (kind === 'tool') {
|
|
260
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>`;
|
|
261
|
-
}
|
|
262
|
-
if (kind === 'command') {
|
|
263
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`;
|
|
264
|
-
}
|
|
265
|
-
if (kind === 'prompt') {
|
|
266
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="13" y2="13"/></svg>`;
|
|
267
|
-
}
|
|
268
|
-
if (kind === 'skill') {
|
|
269
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`;
|
|
270
|
-
}
|
|
271
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`;
|
|
272
|
-
}
|
|
273
|
-
function chevronSvg() {
|
|
274
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg>`;
|
|
275
|
-
}
|
|
276
|
-
function highlightMarkdown(md) {
|
|
277
|
-
let s = esc(md);
|
|
278
|
-
s = s.replace(/^(#{1,6}\s.*)$/gm, '<span class="md-h">$1</span>');
|
|
279
|
-
s = s.replace(/(```[\s\S]*?```)/g, '<span class="md-code">$1</span>');
|
|
280
|
-
s = s.replace(/(`[^`\n]+`)/g, '<span class="md-icode">$1</span>');
|
|
281
|
-
s = s.replace(/(\*\*[^*\n]+\*\*)/g, '<span class="md-bold">$1</span>');
|
|
282
|
-
s = s.replace(/^(\s*[-*+]\s+)/gm, '<span class="md-bullet">$1</span>');
|
|
283
|
-
return s;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
257
|
function splitSystemPrompt(prompt, cwd) {
|
|
287
258
|
const re = /\n(##\s+[^\n]*?(?:AGENTS|CLAUDE)\.md)\n/g;
|
|
288
259
|
const hits = [];
|
|
@@ -299,19 +270,18 @@ function splitSystemPrompt(prompt, cwd) {
|
|
|
299
270
|
const ctxHeader = prompt.lastIndexOf('\n# Project Context', sysEnd);
|
|
300
271
|
if (ctxHeader > 0) sysEnd = ctxHeader;
|
|
301
272
|
parts.push({ id: 'system-prompt', name: 'system prompt', text: prompt.slice(0, sysEnd).trimEnd(), order: 0 });
|
|
273
|
+
const cwdNorm = normPath(cwd);
|
|
302
274
|
for (let i = 0; i < hits.length; i++) {
|
|
303
275
|
const start = hits[i].end;
|
|
304
276
|
const end = i + 1 < hits.length ? hits[i + 1].index : memoryEnd;
|
|
305
277
|
const pathMatch = hits[i].header.match(/##\s+(.+?\.md)\s*$/);
|
|
306
278
|
const fullPath = pathMatch ? pathMatch[1].trim() : '(memory)';
|
|
307
|
-
const norm = fullPath
|
|
279
|
+
const norm = normPath(fullPath);
|
|
308
280
|
const isUserScope = /\/\.pi\/agent\//.test(norm) || /\/\.agents\//.test(norm);
|
|
309
|
-
const cwdNorm = cwd ? String(cwd).replace(/\\/g, '/').toLowerCase() : null;
|
|
310
281
|
const isProject = !isUserScope && (cwdNorm ? norm.startsWith(cwdNorm) : true);
|
|
311
|
-
const fileName = fullPath.split(/[\\/]/).pop() || 'memory';
|
|
312
282
|
parts.push({
|
|
313
283
|
id: `memory:${i}`,
|
|
314
|
-
name: `${isProject ? 'project' : 'user'} · ${
|
|
284
|
+
name: `${isProject ? 'project' : 'user'} · ${basename(fullPath) || 'memory'}`,
|
|
315
285
|
text: prompt.slice(start, end).trim(),
|
|
316
286
|
path: fullPath,
|
|
317
287
|
order: isProject ? 2 : 1,
|
|
@@ -321,311 +291,483 @@ function splitSystemPrompt(prompt, cwd) {
|
|
|
321
291
|
return parts;
|
|
322
292
|
}
|
|
323
293
|
|
|
324
|
-
function
|
|
325
|
-
|
|
294
|
+
function matchesSearch(items) {
|
|
295
|
+
const q = state.search.trim().toLowerCase();
|
|
296
|
+
if (!q) return items;
|
|
297
|
+
return items.filter((it) => it.name.toLowerCase().includes(q) || (it.source ?? '').toLowerCase().includes(q));
|
|
326
298
|
}
|
|
299
|
+
|
|
300
|
+
function defaultSelection() {
|
|
301
|
+
return getItems().find((x) => x.kind === 'context')?.id ?? null;
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
|
|
305
|
+
//#region ICONS
|
|
306
|
+
const ICONS = {
|
|
307
|
+
tool: '<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>',
|
|
308
|
+
command: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
|
309
|
+
prompt: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="13" y2="13"/>',
|
|
310
|
+
skill: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
|
311
|
+
context: '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
|
|
312
|
+
package: '<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
|
313
|
+
chevron: '<polyline points="9 6 15 12 9 18"/>',
|
|
314
|
+
editor: '<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"/>',
|
|
315
|
+
copy: '<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"/>',
|
|
316
|
+
github: '<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22"/>',
|
|
317
|
+
};
|
|
318
|
+
const icon = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">${ICONS[name] ?? ICONS.context}</svg>`;
|
|
327
319
|
//#endregion
|
|
328
320
|
|
|
329
321
|
//#region RENDER TOPBAR
|
|
330
322
|
function renderTopbar() {
|
|
331
|
-
// Session picker
|
|
332
323
|
const sel = $('sessionSelect');
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const opt = document.createElement('option');
|
|
344
|
-
opt.value = s.id;
|
|
345
|
-
const label = s.name ? s.name : `${s.id.slice(0, 8)}…`;
|
|
346
|
-
const cwd = s.cwd ? ` · ${basename(s.cwd)}` : '';
|
|
347
|
-
opt.textContent = `${label}${cwd}`;
|
|
348
|
-
if (s.id === state.currentSessionId) opt.selected = true;
|
|
349
|
-
sel.appendChild(opt);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
// Project path
|
|
324
|
+
const id = state.currentSessionId;
|
|
325
|
+
const options = state.sessions.map((s) => ({
|
|
326
|
+
id: s.id,
|
|
327
|
+
label: `${s.name || shortId(s.id)}${s.cwd ? ` · ${basename(s.cwd)}` : ''}`,
|
|
328
|
+
}));
|
|
329
|
+
if (id && !state.sessions.some((s) => s.id === id)) options.unshift({ id, label: `${shortId(id)} · not captured` });
|
|
330
|
+
sel.innerHTML = options.length
|
|
331
|
+
? options.map((o) => `<option value="${esc(o.id)}"${o.id === id ? ' selected' : ''}>${esc(o.label)}</option>`).join('')
|
|
332
|
+
: '<option value="">No sessions</option>';
|
|
333
|
+
sel.disabled = !options.length;
|
|
353
334
|
$('projectPath').textContent = state.snapshot?.cwd ?? '—';
|
|
354
335
|
}
|
|
336
|
+
|
|
337
|
+
function renderChips(matched) {
|
|
338
|
+
const counts = { all: matched.length };
|
|
339
|
+
for (const it of matched) counts[it.kind] = (counts[it.kind] ?? 0) + 1;
|
|
340
|
+
$('kindChips').innerHTML = CHIP_KINDS.map((k, i) => {
|
|
341
|
+
const on = state.kind === k;
|
|
342
|
+
return `<button class="chip${on ? ' active' : ''}" role="radio" aria-checked="${on}" data-kind="${k}" title="${KIND_LABEL[k]} (${i})">
|
|
343
|
+
<kbd>${i}</kbd><span>${KIND_LABEL[k]}</span><span class="chip-count">${counts[k] ?? 0}</span>
|
|
344
|
+
</button>`;
|
|
345
|
+
}).join('');
|
|
346
|
+
}
|
|
355
347
|
//#endregion
|
|
356
348
|
|
|
357
349
|
//#region RENDER TREE
|
|
350
|
+
function emptyStateHtml() {
|
|
351
|
+
if (state.staticMode) return 'No snapshot in this link. Open a share link, or run pi with pi-inspect loaded.';
|
|
352
|
+
const id = state.currentSessionId;
|
|
353
|
+
if (!id) {
|
|
354
|
+
return state.sessions.length
|
|
355
|
+
? 'Cannot read the latest snapshot.'
|
|
356
|
+
: 'No snapshots yet. Start pi with pi-inspect loaded, or run <code>/inspect snapshot</code> in a pi session.';
|
|
357
|
+
}
|
|
358
|
+
if (!state.sessions.some((s) => s.id === id)) {
|
|
359
|
+
return `<strong>Session ${esc(shortId(id))} was not captured.</strong>
|
|
360
|
+
pi-inspect captures a session when it starts with the extension loaded.
|
|
361
|
+
To capture this session now, run <code>/inspect snapshot</code> in it.`;
|
|
362
|
+
}
|
|
363
|
+
return 'The snapshot for this session is missing or cannot be read. Run <code>/inspect snapshot</code> in the session.';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function groupRow(index, label, iconName, list, level, expanded, key) {
|
|
367
|
+
return `
|
|
368
|
+
<div class="tree-row tree-group level-${level}" id="row-${index}" data-index="${index}" role="treeitem" aria-level="${level}" aria-expanded="${expanded}" data-key="${esc(key)}">
|
|
369
|
+
<span class="tree-chevron">${icon('chevron')}</span>
|
|
370
|
+
<span class="tree-icon">${icon(iconName)}</span>
|
|
371
|
+
<span class="tree-label">${esc(label)}</span>
|
|
372
|
+
<span class="spacer"></span>
|
|
373
|
+
<span class="tree-meta">${list.length} · ${fmtChars(sumChars(list))}</span>
|
|
374
|
+
</div>`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function itemRow(index, it, level) {
|
|
378
|
+
const cls = [it.disabled && 'disabled', state.selected === it.id && 'selected'].filter(Boolean).join(' ');
|
|
379
|
+
const meta = it.kind === 'context' ? fmtChars(it.chars) : it.source;
|
|
380
|
+
const badge = it.disabled
|
|
381
|
+
? '<span class="tree-badge">disabled</span>'
|
|
382
|
+
: it.pending
|
|
383
|
+
? '<span class="tree-badge warn" title="Enabled in settings, active after a pi restart">restart pi</span>'
|
|
384
|
+
: '';
|
|
385
|
+
return `
|
|
386
|
+
<div class="tree-row level-${level} ${cls}" id="row-${index}" data-index="${index}" role="treeitem" aria-level="${level}" aria-selected="${state.selected === it.id}" data-key="${esc(it.id)}">
|
|
387
|
+
<span class="tree-icon">${icon(it.kind)}</span>
|
|
388
|
+
<span class="tree-label">${esc(it.name)}</span>${badge}
|
|
389
|
+
<span class="tree-desc">${esc(it.description)}</span>
|
|
390
|
+
${level < 3 ? `<span class="tree-meta">${esc(meta)}</span>` : ''}
|
|
391
|
+
</div>`;
|
|
392
|
+
}
|
|
393
|
+
|
|
358
394
|
function renderTree() {
|
|
359
395
|
const root = $('treeContainer');
|
|
360
|
-
const
|
|
396
|
+
const matched = matchesSearch(getItems());
|
|
397
|
+
renderChips(matched);
|
|
398
|
+
const rows = [];
|
|
399
|
+
const html = [];
|
|
400
|
+
state.visibleRows = rows;
|
|
361
401
|
if (!state.snapshot) {
|
|
362
|
-
root.innerHTML = state
|
|
363
|
-
? `<div class="loading">No snapshot in this URL. Open a shared link, or run pi /inspect locally.</div>`
|
|
364
|
-
: `<div class="loading">No snapshot for this session. Run <code>/inspect snapshot</code> in a pi session.</div>`;
|
|
402
|
+
root.innerHTML = `<div class="empty-state">${emptyStateHtml()}</div>`;
|
|
365
403
|
return;
|
|
366
404
|
}
|
|
405
|
+
const items = state.kind === 'all' ? matched : matched.filter((it) => it.kind === state.kind);
|
|
367
406
|
if (!items.length) {
|
|
368
|
-
root.innerHTML =
|
|
407
|
+
root.innerHTML = '<div class="empty-state">No items match the filter.</div>';
|
|
369
408
|
return;
|
|
370
409
|
}
|
|
371
410
|
|
|
372
|
-
const groups = new Map();
|
|
373
|
-
for (const k of KIND_ORDER) groups.set(k, []);
|
|
374
|
-
for (const it of items) {
|
|
375
|
-
const g = groups.get(it.kind);
|
|
376
|
-
if (g) g.push(it);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const html = [];
|
|
380
|
-
const rows = [];
|
|
381
411
|
for (const kind of KIND_ORDER) {
|
|
382
|
-
const list =
|
|
412
|
+
const list = items.filter((it) => it.kind === kind);
|
|
383
413
|
if (!list.length) continue;
|
|
384
|
-
const expanded = state.
|
|
414
|
+
const expanded = !state.collapsed.has(kind);
|
|
415
|
+
html.push(groupRow(rows.length, KIND_LABEL[kind], kind, list, 1, expanded, kind));
|
|
385
416
|
rows.push({ type: 'group', key: kind });
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
`);
|
|
396
|
-
if (expanded) {
|
|
397
|
-
const bySource = new Map();
|
|
417
|
+
if (!expanded) continue;
|
|
418
|
+
|
|
419
|
+
const bySource = new Map();
|
|
420
|
+
for (const it of list) {
|
|
421
|
+
const k = it.source || '(unknown)';
|
|
422
|
+
if (!bySource.has(k)) bySource.set(k, []);
|
|
423
|
+
bySource.get(k).push(it);
|
|
424
|
+
}
|
|
425
|
+
if (bySource.size <= 1 || kind === 'context') {
|
|
398
426
|
for (const it of list) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
bySource.get(k).push(it);
|
|
427
|
+
html.push(itemRow(rows.length, it, 2));
|
|
428
|
+
rows.push({ type: 'item', key: it.id });
|
|
402
429
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const subChars = sumChars(sublist);
|
|
419
|
-
html.push(`
|
|
420
|
-
<div class="tree-row marketplace-row tree-subgroup" data-subgroup="${esc(subKey)}" style="padding-left:24px">
|
|
421
|
-
<div class="tree-chevron ${subExpanded ? 'expanded' : ''}">${chevronSvg()}</div>
|
|
422
|
-
<div class="tree-icon">${packageSvg()}</div>
|
|
423
|
-
<div class="tree-label"><span class="mkt-name">${esc(src)}</span></div>
|
|
424
|
-
<div class="spacer"></div>
|
|
425
|
-
<div class="tree-meta">${sublist.length} · ${fmtChars(subChars)}</div>
|
|
426
|
-
</div>
|
|
427
|
-
`);
|
|
428
|
-
}
|
|
429
|
-
if (!useSubgroups || subExpanded) {
|
|
430
|
-
for (const it of sublist) {
|
|
431
|
-
const selected = state.selected === it.id ? 'selected' : '';
|
|
432
|
-
const pad = useSubgroups ? 48 : 32;
|
|
433
|
-
rows.push({ type: 'item', key: it.id });
|
|
434
|
-
const descHtml = it.description
|
|
435
|
-
? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
|
|
436
|
-
: '<div class="spacer"></div>';
|
|
437
|
-
const stateCls = it.disabled ? ' disabled' : (it.pending ? ' pending' : '');
|
|
438
|
-
const stateBadge = it.pending ? `<span class="tree-badge pending" title="Enabled in settings but not yet active — restart pi">restart pi</span>` : '';
|
|
439
|
-
html.push(`
|
|
440
|
-
<div class="tree-row ${selected}${stateCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
|
|
441
|
-
<div class="tree-icon">${iconFor(it.kind)}</div>
|
|
442
|
-
<div class="tree-label">${esc(it.name)}${stateBadge}</div>
|
|
443
|
-
${descHtml}
|
|
444
|
-
<div class="tree-meta">${esc(it.source)}</div>
|
|
445
|
-
</div>
|
|
446
|
-
`);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const sources = [...bySource.keys()].sort(
|
|
433
|
+
(a, b) => ((SOURCE_RANK[a] ?? Infinity) - (SOURCE_RANK[b] ?? Infinity)) || a.localeCompare(b),
|
|
434
|
+
);
|
|
435
|
+
for (const src of sources) {
|
|
436
|
+
const sublist = bySource.get(src);
|
|
437
|
+
const subKey = subgroupKey(kind, src);
|
|
438
|
+
const subExpanded = !state.collapsed.has(subKey);
|
|
439
|
+
html.push(groupRow(rows.length, src, 'package', sublist, 2, subExpanded, subKey));
|
|
440
|
+
rows.push({ type: 'subgroup', key: subKey });
|
|
441
|
+
if (!subExpanded) continue;
|
|
442
|
+
for (const it of sublist) {
|
|
443
|
+
html.push(itemRow(rows.length, it, 3));
|
|
444
|
+
rows.push({ type: 'item', key: it.id });
|
|
449
445
|
}
|
|
450
446
|
}
|
|
451
447
|
}
|
|
452
448
|
root.innerHTML = html.join('');
|
|
453
|
-
state.visibleRows = rows;
|
|
454
449
|
if (state.highlight >= rows.length) state.highlight = rows.length - 1;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
450
|
+
paintHighlight();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function paintHighlight() {
|
|
454
|
+
const root = $('treeContainer');
|
|
455
|
+
root.querySelector('.tree-row.focused')?.classList.remove('focused');
|
|
456
|
+
const el = state.highlight >= 0 ? root.children[state.highlight] : null;
|
|
457
|
+
if (el?.classList.contains('tree-row')) {
|
|
458
|
+
el.classList.add('focused');
|
|
459
|
+
root.setAttribute('aria-activedescendant', el.id);
|
|
460
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
461
|
+
} else {
|
|
462
|
+
root.removeAttribute('aria-activedescendant');
|
|
458
463
|
}
|
|
464
|
+
}
|
|
459
465
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
466
|
+
function selectItem(id) {
|
|
467
|
+
state.selected = id;
|
|
468
|
+
for (const el of $('treeContainer').querySelectorAll('.tree-row[aria-selected]')) {
|
|
469
|
+
const on = el.dataset.key === id;
|
|
470
|
+
el.classList.toggle('selected', on);
|
|
471
|
+
el.setAttribute('aria-selected', String(on));
|
|
472
|
+
}
|
|
473
|
+
renderDetail();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function activateRow(idx) {
|
|
477
|
+
const r = state.visibleRows[idx];
|
|
478
|
+
if (!r) return;
|
|
479
|
+
state.highlight = idx;
|
|
480
|
+
if (r.type === 'item') {
|
|
481
|
+
paintHighlight();
|
|
482
|
+
selectItem(r.key);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
setExpanded(r.key, state.collapsed.has(r.key));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function setExpanded(key, open) {
|
|
489
|
+
if (open) state.collapsed.delete(key);
|
|
490
|
+
else state.collapsed.add(key);
|
|
491
|
+
renderTree();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function setHighlight(idx) {
|
|
495
|
+
const rows = state.visibleRows;
|
|
496
|
+
if (!rows.length) return;
|
|
497
|
+
state.highlight = Math.max(0, Math.min(rows.length - 1, idx));
|
|
498
|
+
paintHighlight();
|
|
499
|
+
const r = rows[state.highlight];
|
|
500
|
+
if (r.type === 'item' && r.key !== state.selected) selectItem(r.key);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function moveHighlight(delta) {
|
|
504
|
+
const rows = state.visibleRows;
|
|
505
|
+
if (!rows.length) return;
|
|
506
|
+
let idx = state.highlight;
|
|
507
|
+
if (idx < 0) {
|
|
508
|
+
const sel = rows.findIndex((r) => r.type === 'item' && r.key === state.selected);
|
|
509
|
+
idx = sel >= 0 ? sel + delta : delta > 0 ? 0 : rows.length - 1;
|
|
510
|
+
} else idx += delta;
|
|
511
|
+
setHighlight(idx);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function expandAtHighlight(open) {
|
|
515
|
+
const r = state.visibleRows[state.highlight];
|
|
516
|
+
if (!r) return;
|
|
517
|
+
if (r.type === 'item') {
|
|
518
|
+
if (open) return;
|
|
519
|
+
for (let i = state.highlight - 1; i >= 0; i--) {
|
|
520
|
+
if (state.visibleRows[i].type !== 'item') return setHighlight(i);
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (state.collapsed.has(r.key) !== open) {
|
|
525
|
+
if (open) setHighlight(state.highlight + 1);
|
|
526
|
+
else if (r.type === 'subgroup') {
|
|
527
|
+
for (let i = state.highlight - 1; i >= 0; i--) {
|
|
528
|
+
if (state.visibleRows[i].type === 'group') return setHighlight(i);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
setExpanded(r.key, open);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function toggleExpandAll() {
|
|
537
|
+
state.expandAll = !state.expandAll;
|
|
538
|
+
state.collapsed.clear();
|
|
539
|
+
if (!state.expandAll) {
|
|
540
|
+
for (const k of KIND_ORDER) state.collapsed.add(k);
|
|
541
|
+
for (const it of getItems()) state.collapsed.add(subgroupKey(it.kind, it.source));
|
|
542
|
+
}
|
|
543
|
+
$('expandToggle').textContent = state.expandAll ? 'Collapse all' : 'Expand all';
|
|
544
|
+
renderTree();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function setKind(kind) {
|
|
548
|
+
if (!CHIP_KINDS.includes(kind) || state.kind === kind) return;
|
|
549
|
+
state.kind = kind;
|
|
550
|
+
state.highlight = -1;
|
|
551
|
+
renderTree();
|
|
482
552
|
}
|
|
483
553
|
//#endregion
|
|
484
554
|
|
|
485
555
|
//#region RENDER DETAIL
|
|
556
|
+
function toggleRequestFor(it) {
|
|
557
|
+
if (it.kind !== 'skill' && it.kind !== 'prompt') return null;
|
|
558
|
+
if (!it.path) return null;
|
|
559
|
+
const scope = it.disabled ? it.raw?.scope : it.raw?.sourceInfo?.scope;
|
|
560
|
+
return {
|
|
561
|
+
action: it.disabled ? 'enable' : 'disable',
|
|
562
|
+
resourceKind: it.kind === 'skill' ? 'skills' : 'prompts',
|
|
563
|
+
path: it.path,
|
|
564
|
+
scope: scope === 'project' ? 'project' : 'user',
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const highlightedText = new WeakMap();
|
|
569
|
+
function promptHtml(it) {
|
|
570
|
+
if (!highlightedText.has(it)) highlightedText.set(it, highlightMarkdown(it.raw.systemPrompt));
|
|
571
|
+
return highlightedText.get(it);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function metaRow(label, value) {
|
|
575
|
+
return value == null || value === '' ? '' : `<dt>${label}</dt><dd>${esc(value)}</dd>`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let detailItem = null;
|
|
486
579
|
function renderDetail() {
|
|
487
580
|
const panel = $('detailPanel');
|
|
488
|
-
|
|
581
|
+
const it = state.snapshot && state.selected ? getItems().find((x) => x.id === state.selected) : null;
|
|
582
|
+
if (it && it === detailItem) return;
|
|
583
|
+
detailItem = it;
|
|
584
|
+
if (!it) {
|
|
489
585
|
panel.innerHTML = `
|
|
490
586
|
<div class="detail-empty">
|
|
491
|
-
|
|
492
|
-
<span
|
|
587
|
+
${icon('context')}
|
|
588
|
+
<span>${state.selected && state.snapshot ? 'This item is not in the snapshot.' : 'Select an item to see its details.'}</span>
|
|
589
|
+
<span class="hint"><kbd>j</kbd> <kbd>k</kbd> to move, <kbd>?</kbd> for all keys</span>
|
|
493
590
|
</div>`;
|
|
494
591
|
return;
|
|
495
592
|
}
|
|
496
|
-
const items = buildItems();
|
|
497
|
-
const it = items.find((x) => x.id === state.selected);
|
|
498
|
-
if (!it) {
|
|
499
|
-
panel.innerHTML = `<div class="detail-empty"><span>Item not found in current snapshot.</span></div>`;
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
593
|
|
|
503
594
|
const isPrompt = it.kind === 'context' && it.raw?.systemPrompt;
|
|
504
|
-
const
|
|
505
|
-
|
|
595
|
+
const si = it.raw?.sourceInfo;
|
|
596
|
+
const status = it.disabled
|
|
597
|
+
? `disabled${it.raw?.scope ? ` (${it.raw.scope})` : ''}`
|
|
598
|
+
: it.pending ? 'restart pi to activate' : it.active === false ? 'inactive' : it.active ? 'active' : null;
|
|
599
|
+
const sections = [];
|
|
506
600
|
if (it.description && !isPrompt) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
</
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
${settingsPath ? `<span class="detail-meta-item">Settings: ${esc(settingsPath)}</span>` : ''}
|
|
526
|
-
</div>
|
|
527
|
-
</div>
|
|
528
|
-
`);
|
|
529
|
-
|
|
530
|
-
if (isPrompt) {
|
|
531
|
-
bodySections.push(`
|
|
532
|
-
<div class="detail-section">
|
|
533
|
-
<h4>System prompt (${it.raw.systemPrompt.length} chars)</h4>
|
|
534
|
-
<pre class="content-viewer-code"><code class="md">${highlightMarkdown(it.raw.systemPrompt)}</code></pre>
|
|
535
|
-
</div>
|
|
536
|
-
`);
|
|
537
|
-
} else {
|
|
538
|
-
bodySections.push(`
|
|
539
|
-
<div class="detail-section">
|
|
540
|
-
<h4>Raw</h4>
|
|
541
|
-
<pre class="content-viewer-code"><code class="json">${highlightJson(it.raw)}</code></pre>
|
|
542
|
-
</div>
|
|
543
|
-
`);
|
|
544
|
-
}
|
|
601
|
+
sections.push(`<section class="detail-section"><h4>Description</h4><p class="detail-desc">${esc(it.description)}</p></section>`);
|
|
602
|
+
}
|
|
603
|
+
sections.push(`
|
|
604
|
+
<section class="detail-section">
|
|
605
|
+
<h4>Details</h4>
|
|
606
|
+
<dl class="meta-grid">
|
|
607
|
+
${metaRow('Kind', it.kind)}
|
|
608
|
+
${metaRow('Source', it.source)}
|
|
609
|
+
${si?.source && packageName(si.source) !== si.source ? metaRow('Package', si.source) : ''}
|
|
610
|
+
${metaRow('Scope', si?.scope)}
|
|
611
|
+
${metaRow('Status', status)}
|
|
612
|
+
${metaRow('Settings', it.raw?.settingsPath)}
|
|
613
|
+
${metaRow('Path', it.path)}
|
|
614
|
+
</dl>
|
|
615
|
+
</section>`);
|
|
616
|
+
sections.push(isPrompt
|
|
617
|
+
? `<section class="detail-section"><h4>Text · ${fmtChars(it.raw.systemPrompt.length)}</h4><pre class="code-view"><code class="md">${promptHtml(it)}</code></pre></section>`
|
|
618
|
+
: `<section class="detail-section"><h4>Raw</h4><pre class="code-view"><code class="json">${highlightJson(it.raw)}</code></pre></section>`);
|
|
545
619
|
|
|
546
620
|
const ghUrl = githubUrlFor(it);
|
|
547
|
-
const
|
|
621
|
+
const toggle = state.staticMode ? null : toggleRequestFor(it);
|
|
622
|
+
const enable = toggle?.action === 'enable';
|
|
548
623
|
const allowEditor = !state.staticMode && it.path;
|
|
549
624
|
panel.innerHTML = `
|
|
550
625
|
<div class="detail-header">
|
|
551
|
-
<
|
|
552
|
-
<
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
</
|
|
626
|
+
<span class="tree-icon">${icon(it.kind)}</span>
|
|
627
|
+
<h3>${esc(it.name)}</h3>
|
|
628
|
+
<span class="kind-tag">${esc(it.kind)}</span>
|
|
629
|
+
<span class="spacer"></span>
|
|
630
|
+
${toggle ? `<button class="btn btn-small" id="toggleBtn" title="${enable ? 'Enable' : 'Disable'} in ${toggle.scope} settings">${enable ? 'Enable' : 'Disable'}</button>` : ''}
|
|
631
|
+
${allowEditor ? `<button class="icon-btn" id="openEditorBtn" title="Open in editor (o)" aria-label="Open in editor">${icon('editor')}</button>` : ''}
|
|
632
|
+
${it.path ? `<button class="icon-btn" id="copyPathBtn" title="Copy path (c)" aria-label="Copy path">${icon('copy')}</button>` : ''}
|
|
633
|
+
${ghUrl ? `<a class="icon-btn" href="${esc(ghUrl)}" target="_blank" rel="noopener" title="Open on GitHub" aria-label="Open on GitHub">${icon('github')}</a>` : ''}
|
|
634
|
+
<button class="icon-btn" id="detailCloseBtn" title="Close (Esc)" aria-label="Close">✕</button>
|
|
559
635
|
</div>
|
|
560
|
-
<div class="detail-body">${
|
|
561
|
-
|
|
562
|
-
$('detailCloseBtn').addEventListener('click',
|
|
563
|
-
|
|
564
|
-
renderTree();
|
|
565
|
-
renderDetail();
|
|
566
|
-
});
|
|
567
|
-
const openBtn = $('openEditorBtn');
|
|
568
|
-
if (openBtn) openBtn.addEventListener('click', async () => {
|
|
636
|
+
<div class="detail-body">${sections.join('')}</div>`;
|
|
637
|
+
|
|
638
|
+
$('detailCloseBtn').addEventListener('click', closeDetail);
|
|
639
|
+
$('openEditorBtn')?.addEventListener('click', async () => {
|
|
569
640
|
try {
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
body: JSON.stringify({ path: it.path }),
|
|
574
|
-
});
|
|
575
|
-
const ct = r.headers.get('content-type') || '';
|
|
576
|
-
if (!ct.includes('application/json')) {
|
|
577
|
-
toast(`Failed: server returned ${r.status} — restart pi-inspect server`);
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
const j = await r.json();
|
|
581
|
-
toast(j.ok ? `Opened in ${j.editor}` : `Failed: ${j.error || 'unknown'}`);
|
|
582
|
-
} catch (e) { toast(`Failed: ${e.message}`); }
|
|
641
|
+
const j = await postJson('/api/open', { path: it.path });
|
|
642
|
+
toast(`Opened in ${j.editor}`, 'success');
|
|
643
|
+
} catch (e) { toast(`Open failed: ${e.message}`, 'error'); }
|
|
583
644
|
});
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
catch { toast('Copy failed'); }
|
|
588
|
-
});
|
|
589
|
-
const ghBtn = $('openGithubBtn');
|
|
590
|
-
if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
|
|
591
|
-
window.open(ghUrl, '_blank', 'noopener');
|
|
645
|
+
$('copyPathBtn')?.addEventListener('click', async () => {
|
|
646
|
+
try { await navigator.clipboard.writeText(it.path); toast('Path copied', 'success'); }
|
|
647
|
+
catch { toast('Copy failed', 'error'); }
|
|
592
648
|
});
|
|
593
649
|
const toggleBtn = $('toggleBtn');
|
|
594
|
-
|
|
595
|
-
if (state.staticMode) { toast('shared view is read-only — run pi-inspect locally to toggle'); return; }
|
|
650
|
+
toggleBtn?.addEventListener('click', async () => {
|
|
596
651
|
toggleBtn.disabled = true;
|
|
597
652
|
try {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
headers: { 'content-type': 'application/json' },
|
|
601
|
-
body: JSON.stringify({
|
|
602
|
-
action: toggleInfo.enable ? 'enable' : 'disable',
|
|
603
|
-
resourceKind: toggleInfo.resourceKind,
|
|
604
|
-
path: toggleInfo.path,
|
|
605
|
-
scope: toggleInfo.scope,
|
|
606
|
-
}),
|
|
607
|
-
});
|
|
608
|
-
const j = await r.json();
|
|
609
|
-
if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
|
|
610
|
-
toast(`${toggleInfo.enable ? 'Enabled' : 'Disabled'} ${it.name} — restart pi to apply fully`);
|
|
653
|
+
await postJson('/api/toggle', toggle);
|
|
654
|
+
toast(`${enable ? 'Enabled' : 'Disabled'} ${it.name}. Restart pi to apply it fully.`, 'success');
|
|
611
655
|
} catch (e) {
|
|
612
|
-
toast(`Toggle failed: ${e.message}
|
|
656
|
+
toast(`Toggle failed: ${e.message}`, 'error');
|
|
613
657
|
toggleBtn.disabled = false;
|
|
614
658
|
}
|
|
615
659
|
});
|
|
616
660
|
}
|
|
617
661
|
|
|
618
|
-
function
|
|
619
|
-
if (
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
662
|
+
function closeDetail() {
|
|
663
|
+
if (!state.selected) return;
|
|
664
|
+
selectItem(null);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function clickDetailButton(id, missing) {
|
|
668
|
+
const btn = $(id);
|
|
669
|
+
if (btn) btn.click();
|
|
670
|
+
else toast(state.selected ? missing : 'Select an item first');
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
|
|
674
|
+
//#region MODALS
|
|
675
|
+
let confirmResolve = null;
|
|
676
|
+
|
|
677
|
+
function isOpen(id) { return $(id).classList.contains('open'); }
|
|
678
|
+
function setModal(id, open) {
|
|
679
|
+
$(id).classList.toggle('open', open);
|
|
680
|
+
$(id).setAttribute('aria-hidden', String(!open));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function confirmDialog(text) {
|
|
684
|
+
$('confirmText').textContent = text;
|
|
685
|
+
setModal('confirmModal', true);
|
|
686
|
+
$('confirmCancel').focus();
|
|
687
|
+
return new Promise((resolve) => { confirmResolve = resolve; });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function settleConfirm(value) {
|
|
691
|
+
setModal('confirmModal', false);
|
|
692
|
+
confirmResolve?.(value);
|
|
693
|
+
confirmResolve = null;
|
|
694
|
+
$('treeContainer').focus();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function trapTab(e, ids) {
|
|
698
|
+
const i = ids.indexOf(document.activeElement?.id);
|
|
699
|
+
e.preventDefault();
|
|
700
|
+
$(ids[(i + (e.shiftKey ? ids.length - 1 : 1)) % ids.length]).focus();
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
|
|
704
|
+
//#region ACTIONS
|
|
705
|
+
function renderAll() {
|
|
706
|
+
renderTopbar();
|
|
707
|
+
renderTree();
|
|
708
|
+
renderDetail();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function switchSession(id, { push = true } = {}) {
|
|
712
|
+
if (push) setUrlSession(id);
|
|
713
|
+
await loadSnapshot(id);
|
|
714
|
+
state.selected = defaultSelection();
|
|
715
|
+
state.highlight = -1;
|
|
716
|
+
renderAll();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function reload() {
|
|
720
|
+
await Promise.all([loadSessions(), loadSnapshot(state.currentSessionId)]);
|
|
721
|
+
renderAll();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function refresh() {
|
|
725
|
+
if (state.staticMode) return;
|
|
726
|
+
const btn = $('refreshBtn');
|
|
727
|
+
btn.classList.add('loading');
|
|
728
|
+
try {
|
|
729
|
+
await reload();
|
|
730
|
+
toast('Refreshed');
|
|
731
|
+
} catch (e) {
|
|
732
|
+
toast(`Refresh failed: ${e.message}`, 'error');
|
|
733
|
+
} finally {
|
|
734
|
+
setTimeout(() => btn.classList.remove('loading'), 200);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function cleanupSessions() {
|
|
739
|
+
const keep = state.currentSessionId;
|
|
740
|
+
if (!keep || !state.sessions.some((s) => s.id === keep)) { toast('Pick a captured session first'); return; }
|
|
741
|
+
const others = state.sessions.length - 1;
|
|
742
|
+
if (!others) { toast('Only one session. Nothing to delete.'); return; }
|
|
743
|
+
if (!(await confirmDialog(`Delete ${others} other snapshot${others === 1 ? '' : 's'} and keep only this session?`))) return;
|
|
744
|
+
const btn = $('cleanupSessionsBtn');
|
|
745
|
+
btn.classList.add('loading');
|
|
746
|
+
try {
|
|
747
|
+
const { removed = [] } = await postJson('/api/sessions/cleanup', { keep });
|
|
748
|
+
await loadSessions();
|
|
749
|
+
renderTopbar();
|
|
750
|
+
toast(removed.length ? `Deleted ${removed.length} snapshot${removed.length === 1 ? '' : 's'}` : 'Nothing to delete', 'success');
|
|
751
|
+
} catch (e) {
|
|
752
|
+
toast(`Delete failed: ${e.message}`, 'error');
|
|
753
|
+
} finally {
|
|
754
|
+
btn.classList.remove('loading');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function share() {
|
|
759
|
+
if (!state.snapshot) { toast('No snapshot to share'); return; }
|
|
760
|
+
const btn = $('shareBtn');
|
|
761
|
+
btn.classList.add('loading');
|
|
762
|
+
try {
|
|
763
|
+
const url = window.piShare.buildShareUrl(await window.piShare.encodeSnapshot(state.snapshot));
|
|
764
|
+
await navigator.clipboard.writeText(url);
|
|
765
|
+
toast(`Share link copied (${Math.max(1, Math.round(url.length / 1024))} KB). Home paths are hidden.`, 'success');
|
|
766
|
+
} catch (e) {
|
|
767
|
+
toast(`Share failed: ${e.message}`, 'error');
|
|
768
|
+
} finally {
|
|
769
|
+
btn.classList.remove('loading');
|
|
625
770
|
}
|
|
626
|
-
const si = it.raw?.sourceInfo;
|
|
627
|
-
const scope = si?.scope === 'project' ? 'project' : 'user';
|
|
628
|
-
return { enable: false, resourceKind, path: it.path, scope };
|
|
629
771
|
}
|
|
630
772
|
//#endregion
|
|
631
773
|
|
|
@@ -637,275 +779,180 @@ function bindResize() {
|
|
|
637
779
|
handle.addEventListener('mousedown', () => {
|
|
638
780
|
dragging = true;
|
|
639
781
|
handle.classList.add('is-dragging');
|
|
640
|
-
document.body.
|
|
641
|
-
document.body.style.userSelect = 'none';
|
|
782
|
+
document.body.classList.add('resizing');
|
|
642
783
|
});
|
|
643
784
|
window.addEventListener('mousemove', (e) => {
|
|
644
785
|
if (!dragging) return;
|
|
645
|
-
const
|
|
646
|
-
const rect = layout.getBoundingClientRect();
|
|
786
|
+
const rect = panel.parentElement.getBoundingClientRect();
|
|
647
787
|
const w = Math.max(220, Math.min(rect.width - 280, e.clientX - rect.left));
|
|
648
|
-
panel.style.
|
|
788
|
+
panel.style.flexBasis = `${w}px`;
|
|
649
789
|
try { localStorage.setItem('inspect.sidebarW', String(w)); } catch {}
|
|
650
790
|
});
|
|
651
791
|
window.addEventListener('mouseup', () => {
|
|
652
792
|
if (!dragging) return;
|
|
653
793
|
dragging = false;
|
|
654
794
|
handle.classList.remove('is-dragging');
|
|
655
|
-
document.body.
|
|
656
|
-
document.body.style.userSelect = '';
|
|
795
|
+
document.body.classList.remove('resizing');
|
|
657
796
|
});
|
|
658
797
|
try {
|
|
659
798
|
const w = Number(localStorage.getItem('inspect.sidebarW'));
|
|
660
|
-
if (w >= 220) panel.style.
|
|
799
|
+
if (w >= 220) panel.style.flexBasis = `${w}px`;
|
|
661
800
|
} catch {}
|
|
662
801
|
}
|
|
663
802
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
803
|
+
const KEYMAP = {
|
|
804
|
+
'?': () => setModal('helpModal', true),
|
|
805
|
+
'/': () => $('searchInput').focus(),
|
|
806
|
+
s: () => { if (!$('sessionSelect').disabled) $('sessionSelect').focus(); },
|
|
807
|
+
r: refresh,
|
|
808
|
+
t: toggleTheme,
|
|
809
|
+
e: toggleExpandAll,
|
|
810
|
+
j: () => moveHighlight(1),
|
|
811
|
+
ArrowDown: () => moveHighlight(1),
|
|
812
|
+
k: () => moveHighlight(-1),
|
|
813
|
+
ArrowUp: () => moveHighlight(-1),
|
|
814
|
+
g: () => setHighlight(0),
|
|
815
|
+
Home: () => setHighlight(0),
|
|
816
|
+
G: () => setHighlight(state.visibleRows.length - 1),
|
|
817
|
+
End: () => setHighlight(state.visibleRows.length - 1),
|
|
818
|
+
l: () => expandAtHighlight(true),
|
|
819
|
+
ArrowRight: () => expandAtHighlight(true),
|
|
820
|
+
h: () => expandAtHighlight(false),
|
|
821
|
+
ArrowLeft: () => expandAtHighlight(false),
|
|
822
|
+
Enter: () => activateRow(state.highlight),
|
|
823
|
+
' ': () => activateRow(state.highlight),
|
|
824
|
+
o: () => clickDetailButton('openEditorBtn', 'This item has no file to open'),
|
|
825
|
+
c: () => clickDetailButton('copyPathBtn', 'This item has no path'),
|
|
826
|
+
Escape: closeDetail,
|
|
827
|
+
};
|
|
828
|
+
CHIP_KINDS.forEach((k, i) => { KEYMAP[String(i)] = () => setKind(k); });
|
|
674
829
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
830
|
+
function handleKeydown(e) {
|
|
831
|
+
if (isOpen('confirmModal')) {
|
|
832
|
+
if (e.key === 'Escape') { e.preventDefault(); settleConfirm(false); }
|
|
833
|
+
else if (e.key === 'Tab') trapTab(e, ['confirmCancel', 'confirmOk']);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (isOpen('helpModal')) {
|
|
837
|
+
if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); setModal('helpModal', false); }
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
841
|
+
const tag = e.target?.tagName;
|
|
842
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
843
|
+
if (e.key === 'Escape') { e.preventDefault(); e.target.blur(); $('treeContainer').focus(); }
|
|
844
|
+
else if (e.key === 'Enter' && tag === 'INPUT') { e.preventDefault(); $('treeContainer').focus(); moveHighlight(1); }
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if ((tag === 'BUTTON' || tag === 'A') && (e.key === 'Enter' || e.key === ' ')) return;
|
|
848
|
+
const action = KEYMAP[e.key];
|
|
849
|
+
if (!action) return;
|
|
850
|
+
e.preventDefault();
|
|
851
|
+
action();
|
|
852
|
+
}
|
|
679
853
|
|
|
854
|
+
function bindEvents() {
|
|
855
|
+
$('sessionSelect').addEventListener('change', (e) => switchSession(e.target.value || null));
|
|
856
|
+
$('kindChips').addEventListener('click', (e) => {
|
|
857
|
+
const chip = e.target.closest('[data-kind]');
|
|
858
|
+
if (chip) setKind(chip.dataset.kind);
|
|
859
|
+
});
|
|
680
860
|
$('searchInput').addEventListener('input', (e) => {
|
|
681
861
|
state.search = e.target.value;
|
|
862
|
+
state.highlight = -1;
|
|
682
863
|
renderTree();
|
|
683
864
|
});
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
if (
|
|
687
|
-
if (!window.piShare) { toast('share module not loaded'); return; }
|
|
688
|
-
const btn = $('shareBtn');
|
|
689
|
-
btn.classList.add('loading');
|
|
690
|
-
try {
|
|
691
|
-
const encoded = await window.piShare.encodeSnapshot(state.snapshot);
|
|
692
|
-
const url = window.piShare.buildShareUrl(encoded);
|
|
693
|
-
await navigator.clipboard.writeText(url);
|
|
694
|
-
const kb = Math.max(1, Math.round(url.length / 1024));
|
|
695
|
-
toast(`Share link copied — ${kb} KB. Paths redacted to <home>.`);
|
|
696
|
-
} catch (e) {
|
|
697
|
-
toast(`Share failed: ${e.message}`);
|
|
698
|
-
} finally {
|
|
699
|
-
btn.classList.remove('loading');
|
|
700
|
-
}
|
|
865
|
+
$('treeContainer').addEventListener('click', (e) => {
|
|
866
|
+
const row = e.target.closest('.tree-row');
|
|
867
|
+
if (row) activateRow(Number(row.dataset.index));
|
|
701
868
|
});
|
|
869
|
+
$('shareBtn').addEventListener('click', share);
|
|
870
|
+
$('refreshBtn').addEventListener('click', refresh);
|
|
871
|
+
$('cleanupSessionsBtn').addEventListener('click', cleanupSessions);
|
|
872
|
+
$('themeBtn').addEventListener('click', toggleTheme);
|
|
873
|
+
$('expandToggle').addEventListener('click', toggleExpandAll);
|
|
874
|
+
$('shortcutsBtn').addEventListener('click', () => setModal('helpModal', true));
|
|
875
|
+
$('helpCloseBtn').addEventListener('click', () => setModal('helpModal', false));
|
|
876
|
+
$('helpModal').addEventListener('click', (e) => { if (e.target === e.currentTarget) setModal('helpModal', false); });
|
|
877
|
+
$('confirmOk').addEventListener('click', () => settleConfirm(true));
|
|
878
|
+
$('confirmCancel').addEventListener('click', () => settleConfirm(false));
|
|
879
|
+
$('confirmModal').addEventListener('click', (e) => { if (e.target === e.currentTarget) settleConfirm(false); });
|
|
880
|
+
window.addEventListener('popstate', () => switchSession(getUrlSession(), { push: false }));
|
|
881
|
+
window.addEventListener('keydown', handleKeydown);
|
|
882
|
+
}
|
|
702
883
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
884
|
+
function bindSse() {
|
|
885
|
+
const es = new EventSource('/api/events');
|
|
886
|
+
es.addEventListener('snapshot', async (ev) => {
|
|
887
|
+
let files = null;
|
|
888
|
+
try { files = JSON.parse(ev.data).files; } catch {}
|
|
889
|
+
const id = state.currentSessionId;
|
|
890
|
+
// Same rule as sanitize() in lib/snapshot.js, which names the snapshot files.
|
|
891
|
+
const ownFile = id && `${id.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`;
|
|
892
|
+
if (!ownFile || !Array.isArray(files) || files.includes(ownFile)) return reload();
|
|
706
893
|
await loadSessions();
|
|
707
|
-
await loadSnapshot(state.currentSessionId);
|
|
708
894
|
renderTopbar();
|
|
709
|
-
renderTree();
|
|
710
|
-
renderDetail();
|
|
711
|
-
setTimeout(() => btn.classList.remove('loading'), 200);
|
|
712
|
-
toast('refreshed');
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
$('cleanupSessionsBtn').addEventListener('click', async () => {
|
|
716
|
-
const keep = state.currentSessionId;
|
|
717
|
-
if (!keep) { toast('select a session first'); return; }
|
|
718
|
-
const others = state.sessions.filter((s) => s.id !== keep).length;
|
|
719
|
-
if (!others) { toast('only one session — nothing to remove'); return; }
|
|
720
|
-
if (!confirm(`Delete ${others} other snapshot${others === 1 ? '' : 's'} and keep only the selected session?`)) return;
|
|
721
|
-
const btn = $('cleanupSessionsBtn');
|
|
722
|
-
btn.classList.add('loading');
|
|
723
|
-
try {
|
|
724
|
-
const r = await fetch('/api/sessions/cleanup', {
|
|
725
|
-
method: 'POST',
|
|
726
|
-
headers: { 'Content-Type': 'application/json' },
|
|
727
|
-
body: JSON.stringify({ keep }),
|
|
728
|
-
});
|
|
729
|
-
const data = await r.json();
|
|
730
|
-
if (!r.ok || data.ok === false) throw new Error(data.error || `HTTP ${r.status}`);
|
|
731
|
-
const n = data.removed?.length ?? 0;
|
|
732
|
-
await loadSessions();
|
|
733
|
-
renderTopbar();
|
|
734
|
-
toast(n ? `removed ${n} session${n === 1 ? '' : 's'}` : 'nothing to remove');
|
|
735
|
-
} catch (e) {
|
|
736
|
-
toast(`cleanup failed: ${e.message}`);
|
|
737
|
-
} finally {
|
|
738
|
-
btn.classList.remove('loading');
|
|
739
|
-
}
|
|
740
895
|
});
|
|
741
|
-
|
|
742
|
-
$('themeBtn').addEventListener('click', () => {
|
|
743
|
-
document.body.classList.toggle('light');
|
|
896
|
+
es.addEventListener('navigate', async (ev) => {
|
|
744
897
|
try {
|
|
745
|
-
|
|
898
|
+
const { session } = JSON.parse(ev.data);
|
|
899
|
+
const sid = session || state.sessions[0]?.id;
|
|
900
|
+
if (!sid) return;
|
|
901
|
+
setUrlSession(sid, true);
|
|
902
|
+
await switchSession(sid, { push: false });
|
|
903
|
+
window.focus();
|
|
746
904
|
} catch {}
|
|
747
905
|
});
|
|
748
|
-
|
|
749
|
-
$('expandToggle').addEventListener('click', () => {
|
|
750
|
-
state.expandAll = !state.expandAll;
|
|
751
|
-
for (const k of KIND_ORDER) state.expanded[k] = state.expandAll;
|
|
752
|
-
const subKeys = new Set(Object.keys(state.expanded).filter((k) => k.includes('::')));
|
|
753
|
-
for (const it of buildItems()) subKeys.add(`${it.kind}::${it.source || '(unknown)'}`);
|
|
754
|
-
for (const key of subKeys) {
|
|
755
|
-
if (state.expandAll) delete state.expanded[key];
|
|
756
|
-
else state.expanded[key] = false;
|
|
757
|
-
}
|
|
758
|
-
$('expandToggle').textContent = state.expandAll ? 'Collapse all' : 'Expand all';
|
|
759
|
-
renderTree();
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
window.addEventListener('popstate', async () => {
|
|
763
|
-
await loadSnapshot(getUrlSession());
|
|
764
|
-
renderTopbar();
|
|
765
|
-
renderTree();
|
|
766
|
-
renderDetail();
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
window.addEventListener('keydown', handleKeydown);
|
|
770
|
-
|
|
771
|
-
document.addEventListener('selectionchange', () => {
|
|
772
|
-
const sel = document.getSelection();
|
|
773
|
-
if (!sel || sel.isCollapsed) return;
|
|
774
|
-
const node = sel.anchorNode;
|
|
775
|
-
const host = node?.nodeType === 1 ? node : node?.parentElement;
|
|
776
|
-
const row = host?.closest?.('.tree-row[data-item]');
|
|
777
|
-
if (!row) return;
|
|
778
|
-
const id = row.dataset.item;
|
|
779
|
-
const root = $('treeContainer');
|
|
780
|
-
const idx = Array.prototype.indexOf.call(root.children, row);
|
|
781
|
-
if (idx >= 0) state.highlight = idx;
|
|
782
|
-
if (state.selected !== id) {
|
|
783
|
-
state.selected = id;
|
|
784
|
-
renderTree();
|
|
785
|
-
renderDetail();
|
|
786
|
-
}
|
|
787
|
-
row.scrollIntoView({ block: 'nearest' });
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
$('shortcutsBtn').addEventListener('click', showHelpModal);
|
|
791
|
-
$('helpCloseBtn').addEventListener('click', hideHelpModal);
|
|
792
|
-
$('helpModal').addEventListener('click', (e) => {
|
|
793
|
-
if (e.target === $('helpModal')) hideHelpModal();
|
|
794
|
-
});
|
|
795
906
|
}
|
|
907
|
+
//#endregion
|
|
796
908
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
if (prev >= 0) root.children[prev]?.classList.remove('focused');
|
|
811
|
-
const el = root.children[idx];
|
|
812
|
-
if (el) {
|
|
813
|
-
el.classList.add('focused');
|
|
814
|
-
el.scrollIntoView({ block: 'nearest' });
|
|
815
|
-
}
|
|
816
|
-
}
|
|
909
|
+
//#region THEME
|
|
910
|
+
// Companion embed contract with a framing host (the pi-kanban board): with ?embed=1 the dashboard
|
|
911
|
+
// posts `pi-companion:ready` to its parent, and the parent answers with `pi-companion:theme`
|
|
912
|
+
// ({ mode, colors }) then and on each theme change. The theme is not saved.
|
|
913
|
+
const EMBEDDED = window.parent !== window && new URLSearchParams(location.search).get('embed') === '1';
|
|
914
|
+
const THEME_KEY = 'inspect.theme';
|
|
915
|
+
const darkQuery = matchMedia('(prefers-color-scheme: dark)');
|
|
916
|
+
const THEME_KEYS = [
|
|
917
|
+
'bgDeep', 'bgSurface', 'bgElevated', 'bgHover', 'border',
|
|
918
|
+
'textPrimary', 'textSecondary', 'textTertiary', 'textMuted',
|
|
919
|
+
'accent', 'accentText', 'success', 'warning', 'error',
|
|
920
|
+
];
|
|
921
|
+
const cssVar = (key) => `--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
817
922
|
|
|
818
|
-
function
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const root = $('treeContainer');
|
|
822
|
-
const el = root.children[state.highlight];
|
|
823
|
-
if (el) el.click();
|
|
923
|
+
function savedTheme() {
|
|
924
|
+
if (EMBEDDED) return null;
|
|
925
|
+
try { return localStorage.getItem(THEME_KEY); } catch { return null; }
|
|
824
926
|
}
|
|
825
927
|
|
|
826
|
-
function
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
if (r.type === 'group') {
|
|
830
|
-
state.expanded[r.key] = open;
|
|
831
|
-
renderTree();
|
|
832
|
-
} else if (r.type === 'subgroup') {
|
|
833
|
-
if (open) delete state.expanded[r.key];
|
|
834
|
-
else state.expanded[r.key] = false;
|
|
835
|
-
renderTree();
|
|
836
|
-
} else if (r.type === 'item' && !open) {
|
|
837
|
-
for (let i = state.highlight - 1; i >= 0; i--) {
|
|
838
|
-
if (state.visibleRows[i].type === 'group' || state.visibleRows[i].type === 'subgroup') {
|
|
839
|
-
state.highlight = i;
|
|
840
|
-
renderTree();
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
}
|
|
928
|
+
function applyLocalTheme() {
|
|
929
|
+
const theme = savedTheme() ?? (darkQuery.matches ? 'dark' : 'light');
|
|
930
|
+
document.body.classList.toggle('light', theme === 'light');
|
|
845
931
|
}
|
|
846
932
|
|
|
847
|
-
function
|
|
848
|
-
|
|
849
|
-
|
|
933
|
+
function toggleTheme() {
|
|
934
|
+
if (EMBEDDED) return;
|
|
935
|
+
const light = document.body.classList.toggle('light');
|
|
936
|
+
try { localStorage.setItem(THEME_KEY, light ? 'light' : 'dark'); } catch {}
|
|
937
|
+
}
|
|
850
938
|
|
|
851
|
-
function
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
const tag = e.target?.tagName;
|
|
860
|
-
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
861
|
-
if (e.key === 'Escape') {
|
|
862
|
-
e.target.blur();
|
|
863
|
-
e.preventDefault();
|
|
864
|
-
}
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
if (e.key === '?') { e.preventDefault(); showHelpModal(); return; }
|
|
868
|
-
if (matchKey(e, '/')) { e.preventDefault(); $('searchInput').focus(); return; }
|
|
869
|
-
if (matchKey(e, 'f', 'F')) { e.preventDefault(); $('kindFilter').focus(); return; }
|
|
870
|
-
if (matchKey(e, 'r', 'R')) { e.preventDefault(); $('refreshBtn').click(); return; }
|
|
871
|
-
if (matchKey(e, 't', 'T')) { e.preventDefault(); $('themeBtn').click(); return; }
|
|
872
|
-
if (matchKey(e, 'e', 'E')) { e.preventDefault(); $('expandToggle').click(); return; }
|
|
873
|
-
if (matchKey(e, 'j', 'ArrowDown')) { e.preventDefault(); moveHighlight(1); return; }
|
|
874
|
-
if (matchKey(e, 'k', 'ArrowUp')) { e.preventDefault(); moveHighlight(-1); return; }
|
|
875
|
-
if (matchKey(e, 'l', 'ArrowRight')) { e.preventDefault(); expandAtHighlight(true); return; }
|
|
876
|
-
if (matchKey(e, 'h', 'ArrowLeft')) { e.preventDefault(); expandAtHighlight(false); return; }
|
|
877
|
-
if (matchKey(e, 'Enter', ' ', 'Space')) { e.preventDefault(); activateHighlight(); return; }
|
|
878
|
-
if (e.key === 'Escape') {
|
|
879
|
-
if (state.selected) { state.selected = null; renderTree(); renderDetail(); }
|
|
939
|
+
function applyHostTheme({ mode, colors }) {
|
|
940
|
+
document.body.classList.toggle('light', mode === 'light');
|
|
941
|
+
for (const key of THEME_KEYS) {
|
|
942
|
+
const v = colors?.[key];
|
|
943
|
+
if (typeof v === 'string' && CSS.supports('color', v)) document.body.style.setProperty(cssVar(key), v);
|
|
880
944
|
}
|
|
881
945
|
}
|
|
882
946
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
renderTopbar();
|
|
889
|
-
renderTree();
|
|
890
|
-
renderDetail();
|
|
947
|
+
applyLocalTheme();
|
|
948
|
+
if (EMBEDDED) {
|
|
949
|
+
document.body.classList.add('embedded');
|
|
950
|
+
window.addEventListener('message', (e) => {
|
|
951
|
+
if (e.source === window.parent && e.data?.type === 'pi-companion:theme') applyHostTheme(e.data);
|
|
891
952
|
});
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
const sid = session || (state.sessions[0] && state.sessions[0].id);
|
|
896
|
-
if (!sid) return;
|
|
897
|
-
const u = new URL(window.location.href);
|
|
898
|
-
u.searchParams.set('session', sid);
|
|
899
|
-
window.history.replaceState({}, '', u);
|
|
900
|
-
state.currentSessionId = sid;
|
|
901
|
-
await loadSnapshot(sid);
|
|
902
|
-
renderTopbar();
|
|
903
|
-
renderTree();
|
|
904
|
-
renderDetail();
|
|
905
|
-
try { window.focus(); } catch {}
|
|
906
|
-
} catch {}
|
|
907
|
-
});
|
|
908
|
-
es.onerror = () => {};
|
|
953
|
+
window.parent.postMessage({ type: 'pi-companion:ready' }, '*');
|
|
954
|
+
} else {
|
|
955
|
+
darkQuery.addEventListener('change', () => { if (!savedTheme()) applyLocalTheme(); });
|
|
909
956
|
}
|
|
910
957
|
//#endregion
|
|
911
958
|
|
|
@@ -923,35 +970,19 @@ async function loadSharedSnapshot() {
|
|
|
923
970
|
return true;
|
|
924
971
|
} catch (e) {
|
|
925
972
|
console.warn('Failed to decode shared snapshot:', e);
|
|
926
|
-
toast(`
|
|
973
|
+
toast(`Cannot read the share link: ${e.message}`, 'error');
|
|
927
974
|
return false;
|
|
928
975
|
}
|
|
929
976
|
}
|
|
930
977
|
|
|
931
|
-
function applyStaticModeUi() {
|
|
932
|
-
document.body.classList.add('static-mode');
|
|
933
|
-
for (const id of ['sessionSelect', 'cleanupSessionsBtn', 'refreshBtn']) {
|
|
934
|
-
const el = $(id);
|
|
935
|
-
if (el) el.style.display = 'none';
|
|
936
|
-
}
|
|
937
|
-
const share = $('shareBtn');
|
|
938
|
-
if (share) share.title = 'Re-copy this shared snapshot link';
|
|
939
|
-
}
|
|
940
|
-
|
|
941
978
|
(async function init() {
|
|
942
|
-
try {
|
|
943
|
-
if (localStorage.getItem('inspect.theme') === 'light') document.body.classList.add('light');
|
|
944
|
-
} catch {}
|
|
945
|
-
|
|
946
979
|
bindResize();
|
|
947
980
|
bindEvents();
|
|
948
981
|
|
|
949
|
-
|
|
950
|
-
if (!sharedLoaded) {
|
|
982
|
+
if (!(await loadSharedSnapshot())) {
|
|
951
983
|
try {
|
|
952
|
-
await loadSessions();
|
|
953
984
|
const requested = getUrlSession();
|
|
954
|
-
await loadSnapshot(requested);
|
|
985
|
+
await Promise.all([loadSessions(), loadSnapshot(requested)]);
|
|
955
986
|
if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
|
|
956
987
|
} catch {
|
|
957
988
|
state.staticMode = true;
|
|
@@ -959,16 +990,9 @@ function applyStaticModeUi() {
|
|
|
959
990
|
}
|
|
960
991
|
}
|
|
961
992
|
|
|
962
|
-
if (state.staticMode)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const firstCtx = buildItems().find((x) => x.kind === 'context');
|
|
966
|
-
if (firstCtx) state.selected = firstCtx.id;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
renderTopbar();
|
|
970
|
-
renderTree();
|
|
971
|
-
renderDetail();
|
|
993
|
+
if (state.staticMode) document.body.classList.add('static-mode');
|
|
994
|
+
state.selected = defaultSelection();
|
|
995
|
+
renderAll();
|
|
972
996
|
if (!state.staticMode) bindSse();
|
|
973
997
|
})();
|
|
974
998
|
//#endregion
|