pi-inspect 0.4.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 +65 -3
- 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 +676 -591
- 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 +96 -75
- package/public/share.js +119 -0
- package/public/style.css +416 -1245
- package/public/sw.js +14 -20
- 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,18 +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
|
+
staticMode: false,
|
|
13
14
|
};
|
|
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
|
-
}
|
|
20
15
|
//#endregion
|
|
21
16
|
|
|
22
17
|
//#region UTIL
|
|
@@ -41,12 +36,26 @@ function highlightJson(value) {
|
|
|
41
36
|
);
|
|
42
37
|
}
|
|
43
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
|
+
|
|
44
49
|
function basename(p) {
|
|
45
50
|
if (!p) return '';
|
|
46
51
|
const parts = String(p).split(/[\\/]/).filter(Boolean);
|
|
47
52
|
return parts[parts.length - 1] ?? '';
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
function shortId(id) {
|
|
56
|
+
return id.length > 12 ? `${id.slice(0, 8)}…` : id;
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
function getUrlSession() {
|
|
51
60
|
return new URLSearchParams(location.search).get('session');
|
|
52
61
|
}
|
|
@@ -58,14 +67,21 @@ function setUrlSession(id, replace = false) {
|
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
function toast(msg, kind = 'info') {
|
|
61
|
-
const c = $('toast');
|
|
62
|
-
if (!c) return;
|
|
63
70
|
const el = document.createElement('div');
|
|
64
71
|
el.className = `toast toast-${kind}`;
|
|
65
72
|
el.textContent = msg;
|
|
66
|
-
|
|
73
|
+
$('toast').appendChild(el);
|
|
67
74
|
setTimeout(() => el.remove(), 2500);
|
|
68
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
|
+
}
|
|
69
85
|
//#endregion
|
|
70
86
|
|
|
71
87
|
//#region FETCH
|
|
@@ -75,6 +91,20 @@ async function fetchJson(path) {
|
|
|
75
91
|
return r.json();
|
|
76
92
|
}
|
|
77
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
|
+
|
|
78
108
|
async function loadSessions() {
|
|
79
109
|
const data = await fetchJson('/api/sessions');
|
|
80
110
|
state.sessions = data.sessions ?? [];
|
|
@@ -92,28 +122,33 @@ async function loadSnapshot(sessionId) {
|
|
|
92
122
|
}
|
|
93
123
|
//#endregion
|
|
94
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
|
+
|
|
95
138
|
function inferPath(x) {
|
|
96
139
|
const p = x?.sourceInfo?.path;
|
|
97
140
|
if (!p || typeof p !== 'string') return null;
|
|
98
|
-
if (/^<.*>$/.test(p)) return null;
|
|
141
|
+
if (/^<.*>$/.test(p) || /^[a-z][\w-]+:/i.test(p)) return null;
|
|
99
142
|
return p;
|
|
100
143
|
}
|
|
101
144
|
|
|
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
|
-
|
|
109
145
|
function inferSource(x) {
|
|
110
146
|
const si = x?.sourceInfo;
|
|
111
147
|
if (si) {
|
|
112
148
|
if (si.label) return si.label;
|
|
113
149
|
if (si.source) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return src;
|
|
150
|
+
if (si.source === 'auto' && si.scope) return si.scope;
|
|
151
|
+
return packageName(si.source);
|
|
117
152
|
}
|
|
118
153
|
if (si.origin) return si.origin;
|
|
119
154
|
if (si.kind) return si.kind;
|
|
@@ -121,14 +156,29 @@ function inferSource(x) {
|
|
|
121
156
|
return x?.source ?? 'builtin';
|
|
122
157
|
}
|
|
123
158
|
|
|
124
|
-
|
|
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
|
+
|
|
125
166
|
function normPath(p) {
|
|
126
167
|
if (!p || typeof p !== 'string') return null;
|
|
127
168
|
return p.replace(/\\/g, '/').toLowerCase();
|
|
128
169
|
}
|
|
129
170
|
|
|
130
|
-
|
|
131
|
-
|
|
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) {
|
|
132
182
|
if (!s) return [];
|
|
133
183
|
const items = [];
|
|
134
184
|
const activeSet = new Set(s.activeTools ?? []);
|
|
@@ -140,7 +190,6 @@ function buildItems() {
|
|
|
140
190
|
(s.disabledItems ?? []).map((d) => normPath(d.path)).filter(Boolean),
|
|
141
191
|
);
|
|
142
192
|
for (const t of s.tools ?? []) {
|
|
143
|
-
const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
|
|
144
193
|
const id = `tool:${t.name}`;
|
|
145
194
|
activeIds.add(id);
|
|
146
195
|
items.push({
|
|
@@ -148,8 +197,7 @@ function buildItems() {
|
|
|
148
197
|
id,
|
|
149
198
|
name: t.name ?? '(tool)',
|
|
150
199
|
source: inferSource(t),
|
|
151
|
-
description,
|
|
152
|
-
chars: (t.description ?? '').length,
|
|
200
|
+
...textInfo(t.description),
|
|
153
201
|
active: activeSet.has(t.name),
|
|
154
202
|
path: inferPath(t),
|
|
155
203
|
raw: t,
|
|
@@ -160,55 +208,35 @@ function buildItems() {
|
|
|
160
208
|
if (cp && disabledPaths.has(cp)) continue;
|
|
161
209
|
const name = c.name ?? c.command ?? '';
|
|
162
210
|
const isSkill = name.startsWith('skill:');
|
|
163
|
-
const src = inferSource(c);
|
|
164
211
|
const isPrompt = !isSkill && c.source === 'prompt';
|
|
165
212
|
const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
|
|
166
|
-
const
|
|
167
|
-
const idName = isSkill ? name.slice('skill:'.length) : name;
|
|
168
|
-
const id = `${kind}:${idName}`;
|
|
213
|
+
const id = `${kind}:${isSkill ? name.slice('skill:'.length) : name}`;
|
|
169
214
|
activeIds.add(id);
|
|
170
215
|
items.push({
|
|
171
216
|
kind,
|
|
172
217
|
id,
|
|
173
218
|
name: `/${name}`,
|
|
174
|
-
source:
|
|
175
|
-
description,
|
|
176
|
-
chars: (c.description ?? '').length,
|
|
219
|
+
source: inferSource(c),
|
|
220
|
+
...textInfo(c.description),
|
|
177
221
|
path: inferPath(c),
|
|
178
222
|
raw: c,
|
|
179
223
|
});
|
|
180
224
|
}
|
|
181
|
-
for (const
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
for (const d of s.pendingItems ?? []) {
|
|
198
|
-
const id = `${d.kind}:${d.name}`;
|
|
199
|
-
if (activeIds.has(id)) continue;
|
|
200
|
-
const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
|
|
201
|
-
items.push({
|
|
202
|
-
kind: d.kind,
|
|
203
|
-
id,
|
|
204
|
-
name: d.displayName ?? d.name,
|
|
205
|
-
source: d.source ?? '(package)',
|
|
206
|
-
description,
|
|
207
|
-
chars: (d.description ?? '').length,
|
|
208
|
-
pending: true,
|
|
209
|
-
path: d.path ?? null,
|
|
210
|
-
raw: d,
|
|
211
|
-
});
|
|
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
|
+
}
|
|
212
240
|
}
|
|
213
241
|
if (s.systemPrompt) {
|
|
214
242
|
for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
|
|
@@ -216,8 +244,7 @@ function buildItems() {
|
|
|
216
244
|
kind: 'context',
|
|
217
245
|
id: `context:${part.id}`,
|
|
218
246
|
name: part.name,
|
|
219
|
-
|
|
220
|
-
description: part.text.slice(0, 240).replace(/\s+/g, ' '),
|
|
247
|
+
description: oneLine(part.text.slice(0, 240)),
|
|
221
248
|
chars: part.text.length,
|
|
222
249
|
path: part.path ?? null,
|
|
223
250
|
raw: { systemPrompt: part.text, path: part.path ?? null },
|
|
@@ -227,61 +254,6 @@ function buildItems() {
|
|
|
227
254
|
return items;
|
|
228
255
|
}
|
|
229
256
|
|
|
230
|
-
function fmtChars(n) {
|
|
231
|
-
if (n == null) return '';
|
|
232
|
-
if (n < 1000) return `${n} chars`;
|
|
233
|
-
return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k chars`;
|
|
234
|
-
}
|
|
235
|
-
function sumChars(list) {
|
|
236
|
-
return list.reduce((a, b) => a + (b.chars || 0), 0);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function filterItems(items) {
|
|
240
|
-
const q = state.search.trim().toLowerCase();
|
|
241
|
-
return items.filter((it) => {
|
|
242
|
-
if (state.kind !== 'all' && it.kind !== state.kind) return false;
|
|
243
|
-
if (!q) return true;
|
|
244
|
-
return (
|
|
245
|
-
it.name.toLowerCase().includes(q) ||
|
|
246
|
-
(it.source ?? '').toLowerCase().includes(q)
|
|
247
|
-
);
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const KIND_ORDER = ['context', 'tool', 'command', 'prompt', 'skill'];
|
|
252
|
-
const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', prompt: 'Prompts', skill: 'Skills' };
|
|
253
|
-
const SOURCE_RANK = { user: 0, project: 1, auto: 2, builtin: 3 };
|
|
254
|
-
//#endregion
|
|
255
|
-
|
|
256
|
-
//#region ICONS
|
|
257
|
-
function iconFor(kind) {
|
|
258
|
-
if (kind === 'tool') {
|
|
259
|
-
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>`;
|
|
260
|
-
}
|
|
261
|
-
if (kind === 'command') {
|
|
262
|
-
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>`;
|
|
263
|
-
}
|
|
264
|
-
if (kind === 'prompt') {
|
|
265
|
-
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>`;
|
|
266
|
-
}
|
|
267
|
-
if (kind === 'skill') {
|
|
268
|
-
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>`;
|
|
269
|
-
}
|
|
270
|
-
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>`;
|
|
271
|
-
}
|
|
272
|
-
function chevronSvg() {
|
|
273
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg>`;
|
|
274
|
-
}
|
|
275
|
-
function highlightMarkdown(md) {
|
|
276
|
-
let s = esc(md);
|
|
277
|
-
s = s.replace(/^(#{1,6}\s.*)$/gm, '<span class="md-h">$1</span>');
|
|
278
|
-
s = s.replace(/(```[\s\S]*?```)/g, '<span class="md-code">$1</span>');
|
|
279
|
-
s = s.replace(/(`[^`\n]+`)/g, '<span class="md-icode">$1</span>');
|
|
280
|
-
s = s.replace(/(\*\*[^*\n]+\*\*)/g, '<span class="md-bold">$1</span>');
|
|
281
|
-
s = s.replace(/^(\s*[-*+]\s+)/gm, '<span class="md-bullet">$1</span>');
|
|
282
|
-
return s;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
257
|
function splitSystemPrompt(prompt, cwd) {
|
|
286
258
|
const re = /\n(##\s+[^\n]*?(?:AGENTS|CLAUDE)\.md)\n/g;
|
|
287
259
|
const hits = [];
|
|
@@ -298,19 +270,18 @@ function splitSystemPrompt(prompt, cwd) {
|
|
|
298
270
|
const ctxHeader = prompt.lastIndexOf('\n# Project Context', sysEnd);
|
|
299
271
|
if (ctxHeader > 0) sysEnd = ctxHeader;
|
|
300
272
|
parts.push({ id: 'system-prompt', name: 'system prompt', text: prompt.slice(0, sysEnd).trimEnd(), order: 0 });
|
|
273
|
+
const cwdNorm = normPath(cwd);
|
|
301
274
|
for (let i = 0; i < hits.length; i++) {
|
|
302
275
|
const start = hits[i].end;
|
|
303
276
|
const end = i + 1 < hits.length ? hits[i + 1].index : memoryEnd;
|
|
304
277
|
const pathMatch = hits[i].header.match(/##\s+(.+?\.md)\s*$/);
|
|
305
278
|
const fullPath = pathMatch ? pathMatch[1].trim() : '(memory)';
|
|
306
|
-
const norm = fullPath
|
|
279
|
+
const norm = normPath(fullPath);
|
|
307
280
|
const isUserScope = /\/\.pi\/agent\//.test(norm) || /\/\.agents\//.test(norm);
|
|
308
|
-
const cwdNorm = cwd ? String(cwd).replace(/\\/g, '/').toLowerCase() : null;
|
|
309
281
|
const isProject = !isUserScope && (cwdNorm ? norm.startsWith(cwdNorm) : true);
|
|
310
|
-
const fileName = fullPath.split(/[\\/]/).pop() || 'memory';
|
|
311
282
|
parts.push({
|
|
312
283
|
id: `memory:${i}`,
|
|
313
|
-
name: `${isProject ? 'project' : 'user'} · ${
|
|
284
|
+
name: `${isProject ? 'project' : 'user'} · ${basename(fullPath) || 'memory'}`,
|
|
314
285
|
text: prompt.slice(start, end).trim(),
|
|
315
286
|
path: fullPath,
|
|
316
287
|
order: isProject ? 2 : 1,
|
|
@@ -320,307 +291,483 @@ function splitSystemPrompt(prompt, cwd) {
|
|
|
320
291
|
return parts;
|
|
321
292
|
}
|
|
322
293
|
|
|
323
|
-
function
|
|
324
|
-
|
|
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));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function defaultSelection() {
|
|
301
|
+
return getItems().find((x) => x.kind === 'context')?.id ?? null;
|
|
325
302
|
}
|
|
326
303
|
//#endregion
|
|
327
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>`;
|
|
319
|
+
//#endregion
|
|
320
|
+
|
|
328
321
|
//#region RENDER TOPBAR
|
|
329
322
|
function renderTopbar() {
|
|
330
|
-
// Session picker
|
|
331
323
|
const sel = $('sessionSelect');
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const opt = document.createElement('option');
|
|
343
|
-
opt.value = s.id;
|
|
344
|
-
const label = s.name ? s.name : `${s.id.slice(0, 8)}…`;
|
|
345
|
-
const cwd = s.cwd ? ` · ${basename(s.cwd)}` : '';
|
|
346
|
-
opt.textContent = `${label}${cwd}`;
|
|
347
|
-
if (s.id === state.currentSessionId) opt.selected = true;
|
|
348
|
-
sel.appendChild(opt);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
// 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;
|
|
352
334
|
$('projectPath').textContent = state.snapshot?.cwd ?? '—';
|
|
353
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
|
+
}
|
|
354
347
|
//#endregion
|
|
355
348
|
|
|
356
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
|
+
|
|
357
394
|
function renderTree() {
|
|
358
395
|
const root = $('treeContainer');
|
|
359
|
-
const
|
|
396
|
+
const matched = matchesSearch(getItems());
|
|
397
|
+
renderChips(matched);
|
|
398
|
+
const rows = [];
|
|
399
|
+
const html = [];
|
|
400
|
+
state.visibleRows = rows;
|
|
360
401
|
if (!state.snapshot) {
|
|
361
|
-
root.innerHTML = `<div class="
|
|
402
|
+
root.innerHTML = `<div class="empty-state">${emptyStateHtml()}</div>`;
|
|
362
403
|
return;
|
|
363
404
|
}
|
|
405
|
+
const items = state.kind === 'all' ? matched : matched.filter((it) => it.kind === state.kind);
|
|
364
406
|
if (!items.length) {
|
|
365
|
-
root.innerHTML =
|
|
407
|
+
root.innerHTML = '<div class="empty-state">No items match the filter.</div>';
|
|
366
408
|
return;
|
|
367
409
|
}
|
|
368
410
|
|
|
369
|
-
const groups = new Map();
|
|
370
|
-
for (const k of KIND_ORDER) groups.set(k, []);
|
|
371
|
-
for (const it of items) {
|
|
372
|
-
const g = groups.get(it.kind);
|
|
373
|
-
if (g) g.push(it);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const html = [];
|
|
377
|
-
const rows = [];
|
|
378
411
|
for (const kind of KIND_ORDER) {
|
|
379
|
-
const list =
|
|
412
|
+
const list = items.filter((it) => it.kind === kind);
|
|
380
413
|
if (!list.length) continue;
|
|
381
|
-
const expanded = state.
|
|
414
|
+
const expanded = !state.collapsed.has(kind);
|
|
415
|
+
html.push(groupRow(rows.length, KIND_LABEL[kind], kind, list, 1, expanded, kind));
|
|
382
416
|
rows.push({ type: 'group', key: kind });
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
`);
|
|
393
|
-
if (expanded) {
|
|
394
|
-
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') {
|
|
395
426
|
for (const it of list) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
bySource.get(k).push(it);
|
|
427
|
+
html.push(itemRow(rows.length, it, 2));
|
|
428
|
+
rows.push({ type: 'item', key: it.id });
|
|
399
429
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const subChars = sumChars(sublist);
|
|
416
|
-
html.push(`
|
|
417
|
-
<div class="tree-row marketplace-row tree-subgroup" data-subgroup="${esc(subKey)}" style="padding-left:24px">
|
|
418
|
-
<div class="tree-chevron ${subExpanded ? 'expanded' : ''}">${chevronSvg()}</div>
|
|
419
|
-
<div class="tree-icon">${packageSvg()}</div>
|
|
420
|
-
<div class="tree-label"><span class="mkt-name">${esc(src)}</span></div>
|
|
421
|
-
<div class="spacer"></div>
|
|
422
|
-
<div class="tree-meta">${sublist.length} · ${fmtChars(subChars)}</div>
|
|
423
|
-
</div>
|
|
424
|
-
`);
|
|
425
|
-
}
|
|
426
|
-
if (!useSubgroups || subExpanded) {
|
|
427
|
-
for (const it of sublist) {
|
|
428
|
-
const selected = state.selected === it.id ? 'selected' : '';
|
|
429
|
-
const pad = useSubgroups ? 48 : 32;
|
|
430
|
-
rows.push({ type: 'item', key: it.id });
|
|
431
|
-
const descHtml = it.description
|
|
432
|
-
? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
|
|
433
|
-
: '<div class="spacer"></div>';
|
|
434
|
-
const stateCls = it.disabled ? ' disabled' : (it.pending ? ' pending' : '');
|
|
435
|
-
const stateBadge = it.pending ? `<span class="tree-badge pending" title="Enabled in settings but not yet active — restart pi">restart pi</span>` : '';
|
|
436
|
-
html.push(`
|
|
437
|
-
<div class="tree-row ${selected}${stateCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
|
|
438
|
-
<div class="tree-icon">${iconFor(it.kind)}</div>
|
|
439
|
-
<div class="tree-label">${esc(it.name)}${stateBadge}</div>
|
|
440
|
-
${descHtml}
|
|
441
|
-
<div class="tree-meta">${esc(it.source)}</div>
|
|
442
|
-
</div>
|
|
443
|
-
`);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
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 });
|
|
446
445
|
}
|
|
447
446
|
}
|
|
448
447
|
}
|
|
449
448
|
root.innerHTML = html.join('');
|
|
450
|
-
state.visibleRows = rows;
|
|
451
449
|
if (state.highlight >= rows.length) state.highlight = rows.length - 1;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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');
|
|
455
463
|
}
|
|
464
|
+
}
|
|
456
465
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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();
|
|
479
552
|
}
|
|
480
553
|
//#endregion
|
|
481
554
|
|
|
482
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;
|
|
483
579
|
function renderDetail() {
|
|
484
580
|
const panel = $('detailPanel');
|
|
485
|
-
|
|
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) {
|
|
486
585
|
panel.innerHTML = `
|
|
487
586
|
<div class="detail-empty">
|
|
488
|
-
|
|
489
|
-
<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>
|
|
490
590
|
</div>`;
|
|
491
591
|
return;
|
|
492
592
|
}
|
|
493
|
-
const items = buildItems();
|
|
494
|
-
const it = items.find((x) => x.id === state.selected);
|
|
495
|
-
if (!it) {
|
|
496
|
-
panel.innerHTML = `<div class="detail-empty"><span>Item not found in current snapshot.</span></div>`;
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
593
|
|
|
500
594
|
const isPrompt = it.kind === 'context' && it.raw?.systemPrompt;
|
|
501
|
-
const
|
|
502
|
-
|
|
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 = [];
|
|
503
600
|
if (it.description && !isPrompt) {
|
|
504
|
-
|
|
505
|
-
<div class="detail-section">
|
|
506
|
-
<h4>Description</h4>
|
|
507
|
-
<div class="detail-desc">${esc(it.description)}</div>
|
|
508
|
-
</div>
|
|
509
|
-
`);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const scope = it.raw?.scope;
|
|
513
|
-
const settingsPath = it.raw?.settingsPath;
|
|
514
|
-
bodySections.push(`
|
|
515
|
-
<div class="detail-section">
|
|
516
|
-
<h4>Metadata</h4>
|
|
517
|
-
<div class="detail-meta-row">
|
|
518
|
-
<span class="detail-meta-item">Kind: ${esc(it.kind)}</span>
|
|
519
|
-
<span class="detail-meta-item">Source: ${esc(it.source)}</span>
|
|
520
|
-
${it.active != null ? `<span class="detail-meta-item">Active: ${it.active ? 'yes' : 'no'}</span>` : ''}
|
|
521
|
-
${it.disabled ? `<span class="detail-meta-item">Disabled${scope ? ` (${esc(scope)})` : ''}</span>` : ''}
|
|
522
|
-
${settingsPath ? `<span class="detail-meta-item">Settings: ${esc(settingsPath)}</span>` : ''}
|
|
523
|
-
</div>
|
|
524
|
-
</div>
|
|
525
|
-
`);
|
|
526
|
-
|
|
527
|
-
if (isPrompt) {
|
|
528
|
-
bodySections.push(`
|
|
529
|
-
<div class="detail-section">
|
|
530
|
-
<h4>System prompt (${it.raw.systemPrompt.length} chars)</h4>
|
|
531
|
-
<pre class="content-viewer-code"><code class="md">${highlightMarkdown(it.raw.systemPrompt)}</code></pre>
|
|
532
|
-
</div>
|
|
533
|
-
`);
|
|
534
|
-
} else {
|
|
535
|
-
bodySections.push(`
|
|
536
|
-
<div class="detail-section">
|
|
537
|
-
<h4>Raw</h4>
|
|
538
|
-
<pre class="content-viewer-code"><code class="json">${highlightJson(it.raw)}</code></pre>
|
|
539
|
-
</div>
|
|
540
|
-
`);
|
|
601
|
+
sections.push(`<section class="detail-section"><h4>Description</h4><p class="detail-desc">${esc(it.description)}</p></section>`);
|
|
541
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>`);
|
|
542
619
|
|
|
543
620
|
const ghUrl = githubUrlFor(it);
|
|
544
|
-
const
|
|
621
|
+
const toggle = state.staticMode ? null : toggleRequestFor(it);
|
|
622
|
+
const enable = toggle?.action === 'enable';
|
|
623
|
+
const allowEditor = !state.staticMode && it.path;
|
|
545
624
|
panel.innerHTML = `
|
|
546
625
|
<div class="detail-header">
|
|
547
|
-
<
|
|
548
|
-
<
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
</
|
|
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>
|
|
555
635
|
</div>
|
|
556
|
-
<div class="detail-body">${
|
|
557
|
-
|
|
558
|
-
$('detailCloseBtn').addEventListener('click',
|
|
559
|
-
|
|
560
|
-
renderTree();
|
|
561
|
-
renderDetail();
|
|
562
|
-
});
|
|
563
|
-
const openBtn = $('openEditorBtn');
|
|
564
|
-
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 () => {
|
|
565
640
|
try {
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
body: JSON.stringify({ path: it.path }),
|
|
570
|
-
});
|
|
571
|
-
const ct = r.headers.get('content-type') || '';
|
|
572
|
-
if (!ct.includes('application/json')) {
|
|
573
|
-
toast(`Failed: server returned ${r.status} — restart pi-inspect server`);
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
const j = await r.json();
|
|
577
|
-
toast(j.ok ? `Opened in ${j.editor}` : `Failed: ${j.error || 'unknown'}`);
|
|
578
|
-
} catch (e) { toast(`Failed: ${e.message}`); }
|
|
579
|
-
});
|
|
580
|
-
const copyBtn = $('copyPathBtn');
|
|
581
|
-
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
|
582
|
-
try { await navigator.clipboard.writeText(it.path); toast('Path copied'); }
|
|
583
|
-
catch { toast('Copy failed'); }
|
|
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'); }
|
|
584
644
|
});
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
645
|
+
$('copyPathBtn')?.addEventListener('click', async () => {
|
|
646
|
+
try { await navigator.clipboard.writeText(it.path); toast('Path copied', 'success'); }
|
|
647
|
+
catch { toast('Copy failed', 'error'); }
|
|
588
648
|
});
|
|
589
649
|
const toggleBtn = $('toggleBtn');
|
|
590
|
-
|
|
650
|
+
toggleBtn?.addEventListener('click', async () => {
|
|
591
651
|
toggleBtn.disabled = true;
|
|
592
652
|
try {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
headers: { 'content-type': 'application/json' },
|
|
596
|
-
body: JSON.stringify({
|
|
597
|
-
action: toggleInfo.enable ? 'enable' : 'disable',
|
|
598
|
-
resourceKind: toggleInfo.resourceKind,
|
|
599
|
-
path: toggleInfo.path,
|
|
600
|
-
scope: toggleInfo.scope,
|
|
601
|
-
}),
|
|
602
|
-
});
|
|
603
|
-
const j = await r.json();
|
|
604
|
-
if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
|
|
605
|
-
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');
|
|
606
655
|
} catch (e) {
|
|
607
|
-
toast(`Toggle failed: ${e.message}
|
|
656
|
+
toast(`Toggle failed: ${e.message}`, 'error');
|
|
608
657
|
toggleBtn.disabled = false;
|
|
609
658
|
}
|
|
610
659
|
});
|
|
611
660
|
}
|
|
612
661
|
|
|
613
|
-
function
|
|
614
|
-
if (
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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');
|
|
620
770
|
}
|
|
621
|
-
const si = it.raw?.sourceInfo;
|
|
622
|
-
const scope = si?.scope === 'project' ? 'project' : 'user';
|
|
623
|
-
return { enable: false, resourceKind, path: it.path, scope };
|
|
624
771
|
}
|
|
625
772
|
//#endregion
|
|
626
773
|
|
|
@@ -632,282 +779,220 @@ function bindResize() {
|
|
|
632
779
|
handle.addEventListener('mousedown', () => {
|
|
633
780
|
dragging = true;
|
|
634
781
|
handle.classList.add('is-dragging');
|
|
635
|
-
document.body.
|
|
636
|
-
document.body.style.userSelect = 'none';
|
|
782
|
+
document.body.classList.add('resizing');
|
|
637
783
|
});
|
|
638
784
|
window.addEventListener('mousemove', (e) => {
|
|
639
785
|
if (!dragging) return;
|
|
640
|
-
const
|
|
641
|
-
const rect = layout.getBoundingClientRect();
|
|
786
|
+
const rect = panel.parentElement.getBoundingClientRect();
|
|
642
787
|
const w = Math.max(220, Math.min(rect.width - 280, e.clientX - rect.left));
|
|
643
|
-
panel.style.
|
|
788
|
+
panel.style.flexBasis = `${w}px`;
|
|
644
789
|
try { localStorage.setItem('inspect.sidebarW', String(w)); } catch {}
|
|
645
790
|
});
|
|
646
791
|
window.addEventListener('mouseup', () => {
|
|
647
792
|
if (!dragging) return;
|
|
648
793
|
dragging = false;
|
|
649
794
|
handle.classList.remove('is-dragging');
|
|
650
|
-
document.body.
|
|
651
|
-
document.body.style.userSelect = '';
|
|
795
|
+
document.body.classList.remove('resizing');
|
|
652
796
|
});
|
|
653
797
|
try {
|
|
654
798
|
const w = Number(localStorage.getItem('inspect.sidebarW'));
|
|
655
|
-
if (w >= 220) panel.style.
|
|
799
|
+
if (w >= 220) panel.style.flexBasis = `${w}px`;
|
|
656
800
|
} catch {}
|
|
657
801
|
}
|
|
658
802
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
renderTopbar();
|
|
686
|
-
renderTree();
|
|
687
|
-
renderDetail();
|
|
688
|
-
setTimeout(() => btn.classList.remove('loading'), 200);
|
|
689
|
-
toast('refreshed');
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
$('cleanupSessionsBtn').addEventListener('click', async () => {
|
|
693
|
-
const keep = state.currentSessionId;
|
|
694
|
-
if (!keep) { toast('select a session first'); return; }
|
|
695
|
-
const others = state.sessions.filter((s) => s.id !== keep).length;
|
|
696
|
-
if (!others) { toast('only one session — nothing to remove'); return; }
|
|
697
|
-
if (!confirm(`Delete ${others} other snapshot${others === 1 ? '' : 's'} and keep only the selected session?`)) return;
|
|
698
|
-
const btn = $('cleanupSessionsBtn');
|
|
699
|
-
btn.classList.add('loading');
|
|
700
|
-
try {
|
|
701
|
-
const r = await fetch('/api/sessions/cleanup', {
|
|
702
|
-
method: 'POST',
|
|
703
|
-
headers: { 'Content-Type': 'application/json' },
|
|
704
|
-
body: JSON.stringify({ keep }),
|
|
705
|
-
});
|
|
706
|
-
const data = await r.json();
|
|
707
|
-
if (!r.ok || data.ok === false) throw new Error(data.error || `HTTP ${r.status}`);
|
|
708
|
-
const n = data.removed?.length ?? 0;
|
|
709
|
-
await loadSessions();
|
|
710
|
-
renderTopbar();
|
|
711
|
-
toast(n ? `removed ${n} session${n === 1 ? '' : 's'}` : 'nothing to remove');
|
|
712
|
-
} catch (e) {
|
|
713
|
-
toast(`cleanup failed: ${e.message}`);
|
|
714
|
-
} finally {
|
|
715
|
-
btn.classList.remove('loading');
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
$('themeBtn').addEventListener('click', () => {
|
|
720
|
-
document.body.classList.toggle('light');
|
|
721
|
-
try {
|
|
722
|
-
localStorage.setItem('inspect.theme', document.body.classList.contains('light') ? 'light' : 'dark');
|
|
723
|
-
} catch {}
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
$('expandToggle').addEventListener('click', () => {
|
|
727
|
-
state.expandAll = !state.expandAll;
|
|
728
|
-
for (const k of KIND_ORDER) state.expanded[k] = state.expandAll;
|
|
729
|
-
const subKeys = new Set(Object.keys(state.expanded).filter((k) => k.includes('::')));
|
|
730
|
-
for (const it of buildItems()) subKeys.add(`${it.kind}::${it.source || '(unknown)'}`);
|
|
731
|
-
for (const key of subKeys) {
|
|
732
|
-
if (state.expandAll) delete state.expanded[key];
|
|
733
|
-
else state.expanded[key] = false;
|
|
734
|
-
}
|
|
735
|
-
$('expandToggle').textContent = state.expandAll ? 'Collapse all' : 'Expand all';
|
|
736
|
-
renderTree();
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
window.addEventListener('popstate', async () => {
|
|
740
|
-
await loadSnapshot(getUrlSession());
|
|
741
|
-
renderTopbar();
|
|
742
|
-
renderTree();
|
|
743
|
-
renderDetail();
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
window.addEventListener('keydown', handleKeydown);
|
|
747
|
-
|
|
748
|
-
document.addEventListener('selectionchange', () => {
|
|
749
|
-
const sel = document.getSelection();
|
|
750
|
-
if (!sel || sel.isCollapsed) return;
|
|
751
|
-
const node = sel.anchorNode;
|
|
752
|
-
const host = node?.nodeType === 1 ? node : node?.parentElement;
|
|
753
|
-
const row = host?.closest?.('.tree-row[data-item]');
|
|
754
|
-
if (!row) return;
|
|
755
|
-
const id = row.dataset.item;
|
|
756
|
-
const root = $('treeContainer');
|
|
757
|
-
const idx = Array.prototype.indexOf.call(root.children, row);
|
|
758
|
-
if (idx >= 0) state.highlight = idx;
|
|
759
|
-
if (state.selected !== id) {
|
|
760
|
-
state.selected = id;
|
|
761
|
-
renderTree();
|
|
762
|
-
renderDetail();
|
|
763
|
-
}
|
|
764
|
-
row.scrollIntoView({ block: 'nearest' });
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
$('shortcutsBtn').addEventListener('click', showHelpModal);
|
|
768
|
-
$('helpCloseBtn').addEventListener('click', hideHelpModal);
|
|
769
|
-
$('helpModal').addEventListener('click', (e) => {
|
|
770
|
-
if (e.target === $('helpModal')) hideHelpModal();
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function moveHighlight(delta) {
|
|
775
|
-
const rows = state.visibleRows;
|
|
776
|
-
if (!rows.length) return;
|
|
777
|
-
const prev = state.highlight;
|
|
778
|
-
let idx = prev;
|
|
779
|
-
if (idx < 0) {
|
|
780
|
-
const selIdx = state.selected ? rows.findIndex((r) => r.type === 'item' && r.key === state.selected) : -1;
|
|
781
|
-
if (selIdx >= 0) idx = Math.max(0, Math.min(rows.length - 1, selIdx + delta));
|
|
782
|
-
else idx = delta > 0 ? 0 : rows.length - 1;
|
|
783
|
-
} else idx = Math.max(0, Math.min(rows.length - 1, idx + delta));
|
|
784
|
-
if (idx === prev) return;
|
|
785
|
-
state.highlight = idx;
|
|
786
|
-
const root = $('treeContainer');
|
|
787
|
-
if (prev >= 0) root.children[prev]?.classList.remove('focused');
|
|
788
|
-
const el = root.children[idx];
|
|
789
|
-
if (el) {
|
|
790
|
-
el.classList.add('focused');
|
|
791
|
-
el.scrollIntoView({ block: 'nearest' });
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function activateHighlight() {
|
|
796
|
-
const r = state.visibleRows[state.highlight];
|
|
797
|
-
if (!r) return;
|
|
798
|
-
const root = $('treeContainer');
|
|
799
|
-
const el = root.children[state.highlight];
|
|
800
|
-
if (el) el.click();
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
function expandAtHighlight(open) {
|
|
804
|
-
const r = state.visibleRows[state.highlight];
|
|
805
|
-
if (!r) return;
|
|
806
|
-
if (r.type === 'group') {
|
|
807
|
-
state.expanded[r.key] = open;
|
|
808
|
-
renderTree();
|
|
809
|
-
} else if (r.type === 'subgroup') {
|
|
810
|
-
if (open) delete state.expanded[r.key];
|
|
811
|
-
else state.expanded[r.key] = false;
|
|
812
|
-
renderTree();
|
|
813
|
-
} else if (r.type === 'item' && !open) {
|
|
814
|
-
for (let i = state.highlight - 1; i >= 0; i--) {
|
|
815
|
-
if (state.visibleRows[i].type === 'group' || state.visibleRows[i].type === 'subgroup') {
|
|
816
|
-
state.highlight = i;
|
|
817
|
-
renderTree();
|
|
818
|
-
break;
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function showHelpModal() { $('helpModal').classList.add('open'); }
|
|
825
|
-
function hideHelpModal() { $('helpModal').classList.remove('open'); }
|
|
826
|
-
function isHelpOpen() { return $('helpModal')?.classList.contains('open'); }
|
|
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); });
|
|
827
829
|
|
|
828
830
|
function handleKeydown(e) {
|
|
829
|
-
if (
|
|
830
|
-
if (e.key === 'Escape'
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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); }
|
|
834
838
|
return;
|
|
835
839
|
}
|
|
840
|
+
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
836
841
|
const tag = e.target?.tagName;
|
|
837
842
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
838
|
-
if (e.key === 'Escape') {
|
|
839
|
-
|
|
840
|
-
e.preventDefault();
|
|
841
|
-
}
|
|
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); }
|
|
842
845
|
return;
|
|
843
846
|
}
|
|
844
|
-
if (e.key === '
|
|
845
|
-
|
|
846
|
-
if (
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
}
|
|
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
|
+
});
|
|
860
|
+
$('searchInput').addEventListener('input', (e) => {
|
|
861
|
+
state.search = e.target.value;
|
|
862
|
+
state.highlight = -1;
|
|
863
|
+
renderTree();
|
|
864
|
+
});
|
|
865
|
+
$('treeContainer').addEventListener('click', (e) => {
|
|
866
|
+
const row = e.target.closest('.tree-row');
|
|
867
|
+
if (row) activateRow(Number(row.dataset.index));
|
|
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);
|
|
858
882
|
}
|
|
859
883
|
|
|
860
884
|
function bindSse() {
|
|
861
885
|
const es = new EventSource('/api/events');
|
|
862
|
-
es.addEventListener('snapshot', async () => {
|
|
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();
|
|
863
893
|
await loadSessions();
|
|
864
|
-
await loadSnapshot(state.currentSessionId);
|
|
865
894
|
renderTopbar();
|
|
866
|
-
renderTree();
|
|
867
|
-
renderDetail();
|
|
868
895
|
});
|
|
869
896
|
es.addEventListener('navigate', async (ev) => {
|
|
870
897
|
try {
|
|
871
898
|
const { session } = JSON.parse(ev.data);
|
|
872
|
-
const sid = session ||
|
|
899
|
+
const sid = session || state.sessions[0]?.id;
|
|
873
900
|
if (!sid) return;
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
window.
|
|
877
|
-
state.currentSessionId = sid;
|
|
878
|
-
await loadSnapshot(sid);
|
|
879
|
-
renderTopbar();
|
|
880
|
-
renderTree();
|
|
881
|
-
renderDetail();
|
|
882
|
-
try { window.focus(); } catch {}
|
|
901
|
+
setUrlSession(sid, true);
|
|
902
|
+
await switchSession(sid, { push: false });
|
|
903
|
+
window.focus();
|
|
883
904
|
} catch {}
|
|
884
905
|
});
|
|
885
|
-
|
|
906
|
+
}
|
|
907
|
+
//#endregion
|
|
908
|
+
|
|
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()}`)}`;
|
|
922
|
+
|
|
923
|
+
function savedTheme() {
|
|
924
|
+
if (EMBEDDED) return null;
|
|
925
|
+
try { return localStorage.getItem(THEME_KEY); } catch { return null; }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function applyLocalTheme() {
|
|
929
|
+
const theme = savedTheme() ?? (darkQuery.matches ? 'dark' : 'light');
|
|
930
|
+
document.body.classList.toggle('light', theme === 'light');
|
|
931
|
+
}
|
|
932
|
+
|
|
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
|
+
}
|
|
938
|
+
|
|
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);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
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);
|
|
952
|
+
});
|
|
953
|
+
window.parent.postMessage({ type: 'pi-companion:ready' }, '*');
|
|
954
|
+
} else {
|
|
955
|
+
darkQuery.addEventListener('change', () => { if (!savedTheme()) applyLocalTheme(); });
|
|
886
956
|
}
|
|
887
957
|
//#endregion
|
|
888
958
|
|
|
889
959
|
//#region INIT
|
|
890
|
-
|
|
960
|
+
async function loadSharedSnapshot() {
|
|
961
|
+
const param = window.piShare?.getSharedSnapshotParam();
|
|
962
|
+
if (!param) return false;
|
|
891
963
|
try {
|
|
892
|
-
|
|
893
|
-
|
|
964
|
+
state.snapshot = await window.piShare.decodeSnapshot(param);
|
|
965
|
+
state.currentSessionId = state.snapshot?.sessionId ?? null;
|
|
966
|
+
state.sessions = state.currentSessionId
|
|
967
|
+
? [{ id: state.currentSessionId, name: state.snapshot.sessionName, cwd: state.snapshot.cwd }]
|
|
968
|
+
: [];
|
|
969
|
+
state.staticMode = true;
|
|
970
|
+
return true;
|
|
971
|
+
} catch (e) {
|
|
972
|
+
console.warn('Failed to decode shared snapshot:', e);
|
|
973
|
+
toast(`Cannot read the share link: ${e.message}`, 'error');
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
894
977
|
|
|
978
|
+
(async function init() {
|
|
895
979
|
bindResize();
|
|
896
980
|
bindEvents();
|
|
897
981
|
|
|
898
|
-
await
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
982
|
+
if (!(await loadSharedSnapshot())) {
|
|
983
|
+
try {
|
|
984
|
+
const requested = getUrlSession();
|
|
985
|
+
await Promise.all([loadSessions(), loadSnapshot(requested)]);
|
|
986
|
+
if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
|
|
987
|
+
} catch {
|
|
988
|
+
state.staticMode = true;
|
|
989
|
+
state.snapshot = null;
|
|
990
|
+
}
|
|
906
991
|
}
|
|
907
992
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
bindSse();
|
|
993
|
+
if (state.staticMode) document.body.classList.add('static-mode');
|
|
994
|
+
state.selected = defaultSelection();
|
|
995
|
+
renderAll();
|
|
996
|
+
if (!state.staticMode) bindSse();
|
|
912
997
|
})();
|
|
913
998
|
//#endregion
|