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.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +65 -3
  3. package/extensions/inspect.ts +54 -52
  4. package/lib/agent-dir.js +18 -0
  5. package/lib/app.js +144 -0
  6. package/lib/args.js +30 -0
  7. package/lib/editor.js +26 -0
  8. package/lib/snapshot.js +57 -79
  9. package/lib/watch.js +29 -0
  10. package/package.json +9 -8
  11. package/public/app.js +676 -591
  12. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  13. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  14. package/public/fonts/fonts.css +244 -0
  15. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  16. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  17. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  18. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  19. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  30. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  31. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  32. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  33. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  34. package/public/index.html +96 -75
  35. package/public/share.js +119 -0
  36. package/public/style.css +416 -1245
  37. package/public/sw.js +14 -20
  38. package/server.js +27 -198
  39. package/themes/pi-dark.json +0 -21
  40. 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
- expanded: { context: true, tool: true, command: true, prompt: true, skill: true },
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
- c.appendChild(el);
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
- const src = si.source.startsWith('npm:') ? si.source.slice(4) : si.source;
115
- if (src === 'auto' && si.scope) return si.scope;
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
- //#region MODEL
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
- function buildItems() {
131
- const s = state.snapshot;
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 description = (c.description ?? '').replace(/\s+/g, ' ').trim();
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: src,
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 d of s.disabledItems ?? []) {
182
- const id = `${d.kind}:${d.name}`;
183
- if (activeIds.has(id)) continue;
184
- const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
185
- items.push({
186
- kind: d.kind,
187
- id,
188
- name: d.displayName ?? d.name,
189
- source: d.source ?? '(package)',
190
- description,
191
- chars: (d.description ?? '').length,
192
- disabled: true,
193
- path: d.path ?? null,
194
- raw: d,
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
- source: `${part.text.length} chars`,
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.replace(/\\/g, '/').toLowerCase();
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'} · ${fileName}`,
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 packageSvg() {
324
- return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>`;
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
- sel.innerHTML = '';
333
- if (!state.sessions.length) {
334
- const opt = document.createElement('option');
335
- opt.value = '';
336
- opt.textContent = '(no sessions)';
337
- sel.appendChild(opt);
338
- sel.disabled = true;
339
- } else {
340
- sel.disabled = false;
341
- for (const s of state.sessions) {
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 items = filterItems(buildItems());
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="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>`;
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 = `<div class="loading">No items match the current filter.</div>`;
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 = groups.get(kind);
412
+ const list = items.filter((it) => it.kind === kind);
380
413
  if (!list.length) continue;
381
- const expanded = state.expanded[kind];
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
- const groupChars = sumChars(list);
384
- html.push(`
385
- <div class="tree-row marketplace-row" data-group="${kind}">
386
- <div class="tree-chevron ${expanded ? 'expanded' : ''}">${chevronSvg()}</div>
387
- <div class="tree-icon">${iconFor(kind)}</div>
388
- <div class="tree-label"><span class="mkt-name">${esc(KIND_LABEL[kind])}</span></div>
389
- <div class="spacer"></div>
390
- <div class="tree-meta">${list.length} · ${fmtChars(groupChars)}</div>
391
- </div>
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
- const k = it.source || '(unknown)';
397
- if (!bySource.has(k)) bySource.set(k, []);
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
- const useSubgroups = bySource.size > 1 && kind !== 'context';
401
- const sources = useSubgroups
402
- ? [...bySource.keys()].sort((a, b) => {
403
- const ra = SOURCE_RANK[a] ?? Infinity;
404
- const rb = SOURCE_RANK[b] ?? Infinity;
405
- return (ra - rb) || a.localeCompare(b);
406
- })
407
- : ['__all__'];
408
- if (!useSubgroups) bySource.set('__all__', list);
409
- for (const src of sources) {
410
- const sublist = bySource.get(src);
411
- const subKey = `${kind}::${src}`;
412
- const subExpanded = state.expanded[subKey] !== false;
413
- if (useSubgroups) {
414
- rows.push({ type: 'subgroup', key: subKey });
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
- if (state.highlight >= 0) {
453
- const el = root.children[state.highlight];
454
- if (el) el.classList.add('focused');
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
- root.querySelectorAll('.tree-row[data-group]').forEach((el) => {
458
- el.addEventListener('click', () => {
459
- const k = el.dataset.group;
460
- state.expanded[k] = !state.expanded[k];
461
- renderTree();
462
- });
463
- });
464
- root.querySelectorAll('.tree-row[data-subgroup]').forEach((el) => {
465
- el.addEventListener('click', () => {
466
- const k = el.dataset.subgroup;
467
- state.expanded[k] = state.expanded[k] === false;
468
- renderTree();
469
- });
470
- });
471
- root.querySelectorAll('.tree-row[data-item]').forEach((el) => {
472
- el.addEventListener('click', () => {
473
- state.selected = el.dataset.item;
474
- state.highlight = -1;
475
- renderTree();
476
- renderDetail();
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
- if (!state.selected || !state.snapshot) {
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
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>
489
- <span>Select an item to view details</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 bodySections = [];
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
- bodySections.push(`
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 toggleInfo = toggleInfoFor(it);
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
- <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
548
- <div class="detail-header-actions">
549
- ${toggleInfo ? `<button class="detail-action" id="toggleBtn" title="${toggleInfo.enable ? 'Enable' : 'Disable'} (${toggleInfo.scope})">${toggleInfo.enable ? 'Enable' : 'Disable'}</button>` : ''}
550
- ${it.path ? `<button class="detail-action" id="openEditorBtn" title="Open in $EDITOR"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>` : ''}
551
- ${it.path ? `<button class="detail-action" id="copyPathBtn" title="Copy path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>` : ''}
552
- ${ghUrl ? `<button class="detail-action" id="openGithubBtn" title="Open on GitHub"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.27-1.68-1.27-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.96 10.96 0 015.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56C20.21 21.38 23.5 17.07 23.5 12 23.5 5.65 18.35.5 12 .5z"/></svg></button>` : ''}
553
- <button class="detail-close" id="detailCloseBtn" title="Close">&#10005;</button>
554
- </div>
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">&#10005;</button>
555
635
  </div>
556
- <div class="detail-body">${bodySections.join('')}</div>
557
- `;
558
- $('detailCloseBtn').addEventListener('click', () => {
559
- state.selected = null;
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 r = await fetch('/api/open', {
567
- method: 'POST',
568
- headers: { 'content-type': 'application/json' },
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
- const ghBtn = $('openGithubBtn');
586
- if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
587
- 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'); }
588
648
  });
589
649
  const toggleBtn = $('toggleBtn');
590
- if (toggleBtn && toggleInfo) toggleBtn.addEventListener('click', async () => {
650
+ toggleBtn?.addEventListener('click', async () => {
591
651
  toggleBtn.disabled = true;
592
652
  try {
593
- const r = await fetch('/api/toggle', {
594
- method: 'POST',
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 toggleInfoFor(it) {
614
- if (it.kind !== 'skill' && it.kind !== 'prompt') return null;
615
- if (!it.path) return null;
616
- const resourceKind = it.kind === 'skill' ? 'skills' : 'prompts';
617
- if (it.disabled) {
618
- const scope = it.raw?.scope === 'project' ? 'project' : 'user';
619
- return { enable: true, resourceKind, path: it.path, scope };
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.style.cursor = 'col-resize';
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 layout = panel.parentElement;
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.flex = `0 0 ${w}px`;
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.style.cursor = '';
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.flex = `0 0 ${w}px`;
799
+ if (w >= 220) panel.style.flexBasis = `${w}px`;
656
800
  } catch {}
657
801
  }
658
802
 
659
- function bindEvents() {
660
- $('sessionSelect').addEventListener('change', async (e) => {
661
- const id = e.target.value || null;
662
- setUrlSession(id);
663
- await loadSnapshot(id);
664
- state.selected = null;
665
- renderTopbar();
666
- renderTree();
667
- renderDetail();
668
- });
669
-
670
- $('kindFilter').addEventListener('change', (e) => {
671
- state.kind = e.target.value;
672
- renderTree();
673
- });
674
-
675
- $('searchInput').addEventListener('input', (e) => {
676
- state.search = e.target.value;
677
- renderTree();
678
- });
679
-
680
- $('refreshBtn').addEventListener('click', async () => {
681
- const btn = $('refreshBtn');
682
- btn.classList.add('loading');
683
- await loadSessions();
684
- await loadSnapshot(state.currentSessionId);
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 (isHelpOpen()) {
830
- if (e.key === 'Escape' || e.key === '?') {
831
- e.preventDefault();
832
- hideHelpModal();
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
- e.target.blur();
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 === '?') { e.preventDefault(); showHelpModal(); return; }
845
- if (matchKey(e, '/')) { e.preventDefault(); $('searchInput').focus(); return; }
846
- if (matchKey(e, 'f', 'F')) { e.preventDefault(); $('kindFilter').focus(); return; }
847
- if (matchKey(e, 'r', 'R')) { e.preventDefault(); $('refreshBtn').click(); return; }
848
- if (matchKey(e, 't', 'T')) { e.preventDefault(); $('themeBtn').click(); return; }
849
- if (matchKey(e, 'e', 'E')) { e.preventDefault(); $('expandToggle').click(); return; }
850
- if (matchKey(e, 'j', 'ArrowDown')) { e.preventDefault(); moveHighlight(1); return; }
851
- if (matchKey(e, 'k', 'ArrowUp')) { e.preventDefault(); moveHighlight(-1); return; }
852
- if (matchKey(e, 'l', 'ArrowRight')) { e.preventDefault(); expandAtHighlight(true); return; }
853
- if (matchKey(e, 'h', 'ArrowLeft')) { e.preventDefault(); expandAtHighlight(false); return; }
854
- if (matchKey(e, 'Enter', ' ', 'Space')) { e.preventDefault(); activateHighlight(); return; }
855
- if (e.key === 'Escape') {
856
- if (state.selected) { state.selected = null; renderTree(); renderDetail(); }
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 || (state.sessions[0] && state.sessions[0].id);
899
+ const sid = session || state.sessions[0]?.id;
873
900
  if (!sid) return;
874
- const u = new URL(window.location.href);
875
- u.searchParams.set('session', sid);
876
- window.history.replaceState({}, '', u);
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
- es.onerror = () => {};
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
- (async function init() {
960
+ async function loadSharedSnapshot() {
961
+ const param = window.piShare?.getSharedSnapshotParam();
962
+ if (!param) return false;
891
963
  try {
892
- if (localStorage.getItem('inspect.theme') === 'light') document.body.classList.add('light');
893
- } catch {}
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 loadSessions();
899
- const requested = getUrlSession();
900
- await loadSnapshot(requested);
901
- if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
902
-
903
- if (state.snapshot?.systemPrompt) {
904
- const firstCtx = buildItems().find((x) => x.kind === 'context');
905
- if (firstCtx) state.selected = firstCtx.id;
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
- renderTopbar();
909
- renderTree();
910
- renderDetail();
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