pi-inspect 0.5.0 → 0.6.0

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