pi-inspect 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,10 @@
5
5
 
6
6
  Introspection dashboard for the [pi coding agent](https://pi.dev) — see what's actually loaded into a session: tools, slash commands, skills, and the system prompt injected on init.
7
7
 
8
- ![pi-inspect demo](https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo.png)
8
+ <p align="center">
9
+ <img src="https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo.png" alt="pi-inspect demo" width="49%">
10
+ <img src="https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo-light.png" alt="pi-inspect demo light" width="49%">
11
+ </p>
9
12
 
10
13
  ## Installation
11
14
 
@@ -1,10 +1,10 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { createConnection } from "node:net";
4
4
  import { homedir } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { type ExtensionAPI, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  const extDir = fileURLToPath(new URL(".", import.meta.url));
10
10
  const port = 5462;
@@ -100,6 +100,161 @@ function writeSnapshot(id: string, snapshot: unknown): void {
100
100
  writeFileSync(joinPath(SNAP_DIR, `${sanitize(id)}.json`), JSON.stringify(snapshot, null, 2), "utf8");
101
101
  }
102
102
 
103
+ type DisabledKind = "command" | "skill" | "extension" | "theme";
104
+ type DisabledScope = "user" | "project";
105
+ type DisabledItem = {
106
+ kind: DisabledKind;
107
+ name: string;
108
+ displayName: string;
109
+ description: string;
110
+ source: string;
111
+ scope: DisabledScope;
112
+ settingsPath: string;
113
+ path: string;
114
+ reason: string;
115
+ };
116
+
117
+ const AGENT_DIR = joinPath(homedir(), ".pi", "agent");
118
+ const SETTINGS_PATH = joinPath(AGENT_DIR, "settings.json");
119
+ const NPM_ROOT = joinPath(AGENT_DIR, "npm", "node_modules");
120
+
121
+ type FilterKind = "prompts" | "skills" | "extensions" | "themes";
122
+ const FILTER_KINDS: readonly FilterKind[] = ["prompts", "skills", "extensions", "themes"];
123
+
124
+ const KIND_CFG: Record<FilterKind, { kind: DisabledKind; stripExt: RegExp; display: (n: string) => string }> = {
125
+ prompts: { kind: "command", stripExt: /\.md$/i, display: (n) => `/${n}` },
126
+ skills: { kind: "skill", stripExt: /\.md$/i, display: (n) => `/skill:${n}` },
127
+ extensions: { kind: "extension", stripExt: /\.(ts|js|mjs|cjs)$/i, display: (n) => n },
128
+ themes: { kind: "theme", stripExt: /\.(json|toml|ya?ml)$/i, display: (n) => n },
129
+ };
130
+
131
+ function resolvePackageRoot(source: string): string | null {
132
+ if (source.startsWith("npm:")) return joinPath(NPM_ROOT, source.slice(4));
133
+ let s = source.replace(/\\/g, "/");
134
+ if (s.startsWith("~/")) s = joinPath(homedir(), s.slice(2));
135
+ try { return statSync(s).isDirectory() ? s : null; } catch { return null; }
136
+ }
137
+
138
+ function sourceLabel(source: string): string {
139
+ if (source.startsWith("npm:")) return source.slice(4);
140
+ const norm = source.replace(/\\/g, "/").replace(/\/$/, "");
141
+ return norm.split("/").pop() ?? norm;
142
+ }
143
+
144
+ const describeCache = new Map<string, { mtimeMs: number; desc: string }>();
145
+
146
+ function describeFrom(filePath: string, isDir: boolean): string {
147
+ const path = isDir ? joinPath(filePath, "SKILL.md") : filePath;
148
+ let mtimeMs: number;
149
+ try { mtimeMs = statSync(path).mtimeMs; } catch { return ""; }
150
+ const cached = describeCache.get(path);
151
+ if (cached && cached.mtimeMs === mtimeMs) return cached.desc;
152
+ let desc = "";
153
+ try {
154
+ const { frontmatter, body } = parseFrontmatter<{ description?: string }>(readFileSync(path, "utf8"));
155
+ desc = typeof frontmatter.description === "string" && frontmatter.description
156
+ ? frontmatter.description
157
+ : body.split(/\r?\n/).find((l) => l.trim() && !l.trim().startsWith("#"))?.trim().slice(0, 240) ?? "";
158
+ } catch {}
159
+ describeCache.set(path, { mtimeMs, desc });
160
+ return desc;
161
+ }
162
+
163
+ function nameFromRel(filterKind: FilterKind, rel: string): string {
164
+ const base = rel.split("/").pop() ?? rel;
165
+ if (filterKind === "skills" && /SKILL\.md$/i.test(base)) {
166
+ const parent = rel.replace(/\/SKILL\.md$/i, "").split("/").pop();
167
+ return parent ?? base;
168
+ }
169
+ return base.replace(KIND_CFG[filterKind].stripExt, "");
170
+ }
171
+
172
+ type GroupCtx = {
173
+ root: string;
174
+ label: string;
175
+ scope: DisabledScope;
176
+ settingsPath: string;
177
+ };
178
+
179
+ function pushDisabled(
180
+ items: DisabledItem[],
181
+ filterKind: FilterKind,
182
+ raw: unknown,
183
+ ctx: GroupCtx,
184
+ ): void {
185
+ if (typeof raw !== "string" || !raw.startsWith("-")) return;
186
+ const rel = raw.slice(1).replace(/\\/g, "/");
187
+ const filePath = joinPath(ctx.root, rel);
188
+ let isDir = false;
189
+ try { isDir = statSync(filePath).isDirectory(); } catch { return; }
190
+ const cfg = KIND_CFG[filterKind];
191
+ const name = nameFromRel(filterKind, rel);
192
+ items.push({
193
+ kind: cfg.kind,
194
+ name,
195
+ displayName: cfg.display(name),
196
+ description: describeFrom(filePath, isDir),
197
+ source: ctx.label,
198
+ scope: ctx.scope,
199
+ settingsPath: ctx.settingsPath,
200
+ path: filePath,
201
+ reason: raw,
202
+ });
203
+ }
204
+
205
+ function collectGroup(items: DisabledItem[], group: any, ctx: GroupCtx): void {
206
+ for (const filterKind of FILTER_KINDS) {
207
+ const arr = group?.[filterKind];
208
+ if (!Array.isArray(arr)) continue;
209
+ for (const raw of arr) pushDisabled(items, filterKind, raw, ctx);
210
+ }
211
+ }
212
+
213
+ const settingsCache = new Map<string, { mtimeMs: number; items: DisabledItem[] }>();
214
+
215
+ function readDisabledFrom(
216
+ settingsPath: string,
217
+ baseDir: string,
218
+ scope: DisabledScope,
219
+ ): DisabledItem[] {
220
+ let mtimeMs: number;
221
+ try { mtimeMs = statSync(settingsPath).mtimeMs; } catch { return []; }
222
+ const cached = settingsCache.get(settingsPath);
223
+ if (cached && cached.mtimeMs === mtimeMs) return cached.items;
224
+
225
+ let settings: any;
226
+ try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch { return []; }
227
+ const items: DisabledItem[] = [];
228
+
229
+ collectGroup(items, settings, { root: baseDir, label: scope, scope, settingsPath });
230
+
231
+ const packages = Array.isArray(settings?.packages) ? settings.packages : [];
232
+ for (const entry of packages) {
233
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
234
+ const source = String(entry.source ?? "");
235
+ if (!source) continue;
236
+ const root = resolvePackageRoot(source);
237
+ if (!root) continue;
238
+ collectGroup(items, entry, { root, label: sourceLabel(source), scope, settingsPath });
239
+ }
240
+ settingsCache.set(settingsPath, { mtimeMs, items });
241
+ return items;
242
+ }
243
+
244
+ function discoverDisabledFromPackages(cwd: string | null): DisabledItem[] {
245
+ const scopes: { settings: string; base: string; scope: DisabledScope }[] = [
246
+ { settings: SETTINGS_PATH, base: AGENT_DIR, scope: "user" },
247
+ ];
248
+ if (cwd) scopes.push({ settings: joinPath(cwd, ".pi", "settings.json"), base: joinPath(cwd, ".pi"), scope: "project" });
249
+
250
+ // Project overrides user when both disable the same path.
251
+ const byPath = new Map<string, DisabledItem>();
252
+ for (const { settings, base, scope } of scopes) {
253
+ for (const it of readDisabledFrom(settings, base, scope)) byPath.set(it.path, it);
254
+ }
255
+ return [...byPath.values()];
256
+ }
257
+
103
258
  function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
104
259
  const sm = ctx.sessionManager;
105
260
  const id = sm?.getSessionId?.();
@@ -112,8 +267,9 @@ function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
112
267
  const commands = typeof pi?.getCommands === "function" ? pi.getCommands() : [];
113
268
  const tools = typeof pi?.getAllTools === "function" ? pi.getAllTools() : [];
114
269
  const activeTools = typeof pi?.getActiveTools === "function" ? pi.getActiveTools() : [];
270
+ const disabledItems = discoverDisabledFromPackages(cwd);
115
271
  const capturedAt = Date.now();
116
- const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, capturedAt };
272
+ const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, disabledItems, capturedAt };
117
273
  try {
118
274
  writeSnapshot(id, snap);
119
275
  upsertIndex({ id, cwd, name, model, capturedAt });
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const fsp = require('node:fs/promises');
4
+
5
+ const MAX_WALK = 8;
6
+ const cache = new Map(); // root -> { url, source } | null
7
+
8
+ function normalizeRepo(raw) {
9
+ if (!raw || typeof raw !== 'string') return null;
10
+ let s = raw.trim();
11
+ if (!s) return null;
12
+ s = s.replace(/^git\+/, '').replace(/\.git(\/|$)/, '$1').replace(/\/+$/, '');
13
+ // shorthand: github:owner/repo or owner/repo
14
+ let m = s.match(/^(?:github:)?([\w.-]+)\/([\w.-]+)$/i);
15
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
16
+ // ssh: git@github.com:owner/repo
17
+ m = s.match(/^git@github\.com:([\w.-]+)\/([\w.-]+)$/i);
18
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
19
+ // ssh url: ssh://git@github.com/owner/repo
20
+ m = s.match(/^ssh:\/\/git@github\.com\/([\w.-]+)\/([\w.-]+)$/i);
21
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
22
+ // https / git protocols
23
+ m = s.match(/^(?:https?|git):\/\/(?:[^@/]+@)?github\.com\/([\w.-]+)\/([\w.-]+)$/i);
24
+ if (m) return `https://github.com/${m[1]}/${m[2]}`;
25
+ return null;
26
+ }
27
+
28
+ async function tryPackageJson(dir) {
29
+ try {
30
+ const raw = await fsp.readFile(path.join(dir, 'package.json'), 'utf8');
31
+ const pkg = JSON.parse(raw);
32
+ const repo = pkg.repository;
33
+ let urlStr = null;
34
+ let subdir = null;
35
+ if (typeof repo === 'string') urlStr = repo;
36
+ else if (repo && typeof repo === 'object') {
37
+ urlStr = repo.url;
38
+ if (typeof repo.directory === 'string') subdir = repo.directory.replace(/^\/+|\/+$/g, '');
39
+ }
40
+ const base = normalizeRepo(urlStr);
41
+ if (!base) return null;
42
+ return { url: subdir ? `${base}/tree/HEAD/${subdir}` : base, source: 'package.json' };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async function tryGitConfig(dir) {
49
+ try {
50
+ const raw = await fsp.readFile(path.join(dir, '.git', 'config'), 'utf8');
51
+ // Find [remote "origin"] section then its url
52
+ const re = /\[remote\s+"origin"\]([\s\S]*?)(?=\n\[|\s*$)/;
53
+ const m = re.exec(raw);
54
+ if (!m) return null;
55
+ const urlM = /\burl\s*=\s*(.+)/.exec(m[1]);
56
+ if (!urlM) return null;
57
+ const url = normalizeRepo(urlM[1].trim());
58
+ if (!url) return null;
59
+ return { url, source: 'git' };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ async function resolveGithubUrl(root) {
66
+ if (!root || typeof root !== 'string') return null;
67
+ if (cache.has(root)) return cache.get(root);
68
+ const visited = [];
69
+ let dir = path.resolve(root);
70
+ let last = null;
71
+ for (let i = 0; i < MAX_WALK; i++) {
72
+ if (dir === last) break;
73
+ if (cache.has(dir)) {
74
+ const cached = cache.get(dir);
75
+ for (const v of visited) cache.set(v, cached);
76
+ cache.set(root, cached);
77
+ return cached;
78
+ }
79
+ visited.push(dir);
80
+ const [pj, gc] = await Promise.all([tryPackageJson(dir), tryGitConfig(dir)]);
81
+ const hit = pj || gc;
82
+ if (hit) {
83
+ for (const v of visited) cache.set(v, hit);
84
+ cache.set(root, hit);
85
+ return hit;
86
+ }
87
+ last = dir;
88
+ dir = path.dirname(dir);
89
+ }
90
+ for (const v of visited) cache.set(v, null);
91
+ cache.set(root, null);
92
+ return null;
93
+ }
94
+
95
+ async function resolveMany(roots) {
96
+ const unique = [...new Set(roots.filter((r) => typeof r === 'string' && r))];
97
+ const out = {};
98
+ await Promise.all(unique.map(async (r) => {
99
+ const hit = await resolveGithubUrl(r);
100
+ if (hit) out[r] = hit;
101
+ }));
102
+ return out;
103
+ }
104
+
105
+ module.exports = { resolveGithubUrl, resolveMany, normalizeRepo };
package/lib/snapshot.js CHANGED
@@ -40,6 +40,49 @@ async function readSnapshot(sessionId) {
40
40
  }
41
41
  }
42
42
 
43
+ async function writeIndex(sessions) {
44
+ await fsp.mkdir(SNAPSHOT_DIR, { recursive: true });
45
+ await fsp.writeFile(INDEX_PATH, JSON.stringify({ sessions }, null, 2), 'utf8');
46
+ }
47
+
48
+ async function cleanupIndex() {
49
+ const sessions = await readIndex();
50
+ const kept = [];
51
+ const removed = [];
52
+ for (const s of sessions) {
53
+ try {
54
+ await fsp.access(snapshotPath(s.id));
55
+ kept.push(s);
56
+ } catch {
57
+ removed.push(s.id);
58
+ }
59
+ }
60
+ if (removed.length) await writeIndex(kept);
61
+ return { kept: kept.length, removed };
62
+ }
63
+
64
+ async function keepOnly(sessionId) {
65
+ if (!sessionId) throw new Error('sessionId required');
66
+ const sessions = await readIndex();
67
+ const kept = sessions.filter((s) => s.id === sessionId);
68
+ const removed = [];
69
+ let files;
70
+ try { files = await fsp.readdir(SNAPSHOT_DIR); }
71
+ catch { files = []; }
72
+ const keepPath = path.basename(snapshotPath(sessionId));
73
+ for (const f of files) {
74
+ if (!f.endsWith('.json') || f === 'index.json' || f === keepPath) continue;
75
+ try {
76
+ await fsp.unlink(path.join(SNAPSHOT_DIR, f));
77
+ removed.push(f.replace(/\.json$/, ''));
78
+ } catch (e) {
79
+ console.warn(`pi-inspect: unlink ${f}: ${e.message}`);
80
+ }
81
+ }
82
+ await writeIndex(kept);
83
+ return { kept: kept.length, removed };
84
+ }
85
+
43
86
  async function readLatestSnapshot() {
44
87
  const sessions = await readIndex();
45
88
  if (!sessions.length) return null;
@@ -51,6 +94,9 @@ module.exports = {
51
94
  snapshotDir,
52
95
  snapshotPath,
53
96
  readIndex,
97
+ writeIndex,
98
+ cleanupIndex,
99
+ keepOnly,
54
100
  readSnapshot,
55
101
  readLatestSnapshot,
56
102
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inspect",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Introspection dashboard for the pi coding agent — tools, slash commands, skills, and the system prompt injected on init.",
5
5
  "keywords": [
6
6
  "pi-package",
package/public/app.js CHANGED
@@ -5,11 +5,18 @@ const state = {
5
5
  snapshot: null,
6
6
  search: '',
7
7
  kind: 'all',
8
- expanded: { context: true, tool: true, command: true, skill: true },
8
+ expanded: { context: true, tool: true, command: true, prompt: true, skill: true },
9
9
  selected: null,
10
10
  expandAll: true,
11
+ highlight: -1,
12
+ visibleRows: [],
11
13
  };
12
14
  const els = {};
15
+
16
+ function matchKey(e, ...keys) {
17
+ if (e.ctrlKey || e.altKey || e.metaKey) return false;
18
+ return keys.some((k) => e.key === k || e.code === k);
19
+ }
13
20
  //#endregion
14
21
 
15
22
  //#region UTIL
@@ -92,11 +99,22 @@ function inferPath(x) {
92
99
  return p;
93
100
  }
94
101
 
102
+ function githubUrlFor(it) {
103
+ const map = state.snapshot?.githubSources;
104
+ const root = it.raw?.sourceInfo?.baseDir;
105
+ if (!map || !root) return null;
106
+ return map[root]?.url || null;
107
+ }
108
+
95
109
  function inferSource(x) {
96
110
  const si = x?.sourceInfo;
97
111
  if (si) {
98
112
  if (si.label) return si.label;
99
- if (si.source) return si.source.startsWith('npm:') ? si.source.slice(4) : si.source;
113
+ 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;
117
+ }
100
118
  if (si.origin) return si.origin;
101
119
  if (si.kind) return si.kind;
102
120
  }
@@ -108,14 +126,20 @@ function buildItems() {
108
126
  const s = state.snapshot;
109
127
  if (!s) return [];
110
128
  const items = [];
129
+ const activeSet = new Set(s.activeTools ?? []);
130
+ const activeIds = new Set();
111
131
  for (const t of s.tools ?? []) {
132
+ const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
133
+ const id = `tool:${t.name}`;
134
+ activeIds.add(id);
112
135
  items.push({
113
136
  kind: 'tool',
114
- id: `tool:${t.name}`,
137
+ id,
115
138
  name: t.name ?? '(tool)',
116
139
  source: inferSource(t),
117
- description: t.description ?? '',
118
- active: (s.activeTools ?? []).includes(t.name),
140
+ description,
141
+ chars: (t.description ?? '').length,
142
+ active: activeSet.has(t.name),
119
143
  path: inferPath(t),
120
144
  raw: t,
121
145
  });
@@ -123,16 +147,39 @@ function buildItems() {
123
147
  for (const c of s.commands ?? []) {
124
148
  const name = c.name ?? c.command ?? '';
125
149
  const isSkill = name.startsWith('skill:');
150
+ const src = inferSource(c);
151
+ const isPrompt = !isSkill && c.source === 'prompt';
152
+ const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
153
+ const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
154
+ const id = `${kind}:${name}`;
155
+ activeIds.add(id);
126
156
  items.push({
127
- kind: isSkill ? 'skill' : 'command',
128
- id: `${isSkill ? 'skill' : 'command'}:${name}`,
157
+ kind,
158
+ id,
129
159
  name: `/${name}`,
130
- source: inferSource(c),
131
- description: c.description ?? '',
160
+ source: src,
161
+ description,
162
+ chars: (c.description ?? '').length,
132
163
  path: inferPath(c),
133
164
  raw: c,
134
165
  });
135
166
  }
167
+ for (const d of s.disabledItems ?? []) {
168
+ const id = `${d.kind}:${d.name}`;
169
+ if (activeIds.has(id)) continue;
170
+ const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
171
+ items.push({
172
+ kind: d.kind,
173
+ id,
174
+ name: d.displayName ?? d.name,
175
+ source: d.source ?? '(package)',
176
+ description,
177
+ chars: (d.description ?? '').length,
178
+ disabled: true,
179
+ path: d.path ?? null,
180
+ raw: d,
181
+ });
182
+ }
136
183
  if (s.systemPrompt) {
137
184
  for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
138
185
  items.push({
@@ -141,6 +188,7 @@ function buildItems() {
141
188
  name: part.name,
142
189
  source: `${part.text.length} chars`,
143
190
  description: part.text.slice(0, 240).replace(/\s+/g, ' '),
191
+ chars: part.text.length,
144
192
  path: part.path ?? null,
145
193
  raw: { systemPrompt: part.text, path: part.path ?? null },
146
194
  });
@@ -149,6 +197,15 @@ function buildItems() {
149
197
  return items;
150
198
  }
151
199
 
200
+ function fmtChars(n) {
201
+ if (n == null) return '';
202
+ if (n < 1000) return `${n} chars`;
203
+ return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k chars`;
204
+ }
205
+ function sumChars(list) {
206
+ return list.reduce((a, b) => a + (b.chars || 0), 0);
207
+ }
208
+
152
209
  function filterItems(items) {
153
210
  const q = state.search.trim().toLowerCase();
154
211
  return items.filter((it) => {
@@ -156,13 +213,14 @@ function filterItems(items) {
156
213
  if (!q) return true;
157
214
  return (
158
215
  it.name.toLowerCase().includes(q) ||
159
- (it.description ?? '').toLowerCase().includes(q)
216
+ (it.source ?? '').toLowerCase().includes(q)
160
217
  );
161
218
  });
162
219
  }
163
220
 
164
- const KIND_ORDER = ['context', 'tool', 'command', 'skill'];
165
- const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', skill: 'Skills' };
221
+ const KIND_ORDER = ['context', 'tool', 'command', 'prompt', 'skill'];
222
+ const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', prompt: 'Prompts', skill: 'Skills' };
223
+ const SOURCE_RANK = { user: 0, project: 1, auto: 2, builtin: 3 };
166
224
  //#endregion
167
225
 
168
226
  //#region ICONS
@@ -173,6 +231,9 @@ function iconFor(kind) {
173
231
  if (kind === 'command') {
174
232
  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>`;
175
233
  }
234
+ if (kind === 'prompt') {
235
+ 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>`;
236
+ }
176
237
  if (kind === 'skill') {
177
238
  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>`;
178
239
  }
@@ -280,17 +341,20 @@ function renderTree() {
280
341
  for (const it of items) groups.get(it.kind).push(it);
281
342
 
282
343
  const html = [];
344
+ const rows = [];
283
345
  for (const kind of KIND_ORDER) {
284
346
  const list = groups.get(kind);
285
347
  if (!list.length) continue;
286
348
  const expanded = state.expanded[kind];
349
+ rows.push({ type: 'group', key: kind });
350
+ const groupChars = sumChars(list);
287
351
  html.push(`
288
352
  <div class="tree-row marketplace-row" data-group="${kind}">
289
353
  <div class="tree-chevron ${expanded ? 'expanded' : ''}">${chevronSvg()}</div>
290
354
  <div class="tree-icon">${iconFor(kind)}</div>
291
355
  <div class="tree-label"><span class="mkt-name">${esc(KIND_LABEL[kind])}</span></div>
292
356
  <div class="spacer"></div>
293
- <div class="tree-meta">${list.length}</div>
357
+ <div class="tree-meta">${list.length} · ${fmtChars(groupChars)}</div>
294
358
  </div>
295
359
  `);
296
360
  if (expanded) {
@@ -303,9 +367,9 @@ function renderTree() {
303
367
  const useSubgroups = bySource.size > 1 && kind !== 'context';
304
368
  const sources = useSubgroups
305
369
  ? [...bySource.keys()].sort((a, b) => {
306
- if (a === 'builtin') return -1;
307
- if (b === 'builtin') return 1;
308
- return a.localeCompare(b);
370
+ const ra = SOURCE_RANK[a] ?? Infinity;
371
+ const rb = SOURCE_RANK[b] ?? Infinity;
372
+ return (ra - rb) || a.localeCompare(b);
309
373
  })
310
374
  : ['__all__'];
311
375
  if (!useSubgroups) bySource.set('__all__', list);
@@ -314,13 +378,15 @@ function renderTree() {
314
378
  const subKey = `${kind}::${src}`;
315
379
  const subExpanded = state.expanded[subKey] !== false;
316
380
  if (useSubgroups) {
381
+ rows.push({ type: 'subgroup', key: subKey });
382
+ const subChars = sumChars(sublist);
317
383
  html.push(`
318
384
  <div class="tree-row marketplace-row tree-subgroup" data-subgroup="${esc(subKey)}" style="padding-left:24px">
319
385
  <div class="tree-chevron ${subExpanded ? 'expanded' : ''}">${chevronSvg()}</div>
320
386
  <div class="tree-icon">${packageSvg()}</div>
321
387
  <div class="tree-label"><span class="mkt-name">${esc(src)}</span></div>
322
388
  <div class="spacer"></div>
323
- <div class="tree-meta">${sublist.length}</div>
389
+ <div class="tree-meta">${sublist.length} · ${fmtChars(subChars)}</div>
324
390
  </div>
325
391
  `);
326
392
  }
@@ -328,11 +394,16 @@ function renderTree() {
328
394
  for (const it of sublist) {
329
395
  const selected = state.selected === it.id ? 'selected' : '';
330
396
  const pad = useSubgroups ? 48 : 32;
397
+ rows.push({ type: 'item', key: it.id });
398
+ const descHtml = it.description
399
+ ? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
400
+ : '<div class="spacer"></div>';
401
+ const disabledCls = it.disabled ? ' disabled' : '';
331
402
  html.push(`
332
- <div class="tree-row ${selected}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
403
+ <div class="tree-row ${selected}${disabledCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
333
404
  <div class="tree-icon">${iconFor(it.kind)}</div>
334
405
  <div class="tree-label">${esc(it.name)}</div>
335
- <div class="spacer"></div>
406
+ ${descHtml}
336
407
  <div class="tree-meta">${esc(it.source)}</div>
337
408
  </div>
338
409
  `);
@@ -342,6 +413,12 @@ function renderTree() {
342
413
  }
343
414
  }
344
415
  root.innerHTML = html.join('');
416
+ state.visibleRows = rows;
417
+ if (state.highlight >= rows.length) state.highlight = rows.length - 1;
418
+ if (state.highlight >= 0) {
419
+ const el = root.children[state.highlight];
420
+ if (el) el.classList.add('focused');
421
+ }
345
422
 
346
423
  root.querySelectorAll('.tree-row[data-group]').forEach((el) => {
347
424
  el.addEventListener('click', () => {
@@ -360,6 +437,7 @@ function renderTree() {
360
437
  root.querySelectorAll('.tree-row[data-item]').forEach((el) => {
361
438
  el.addEventListener('click', () => {
362
439
  state.selected = el.dataset.item;
440
+ state.highlight = -1;
363
441
  renderTree();
364
442
  renderDetail();
365
443
  });
@@ -397,6 +475,8 @@ function renderDetail() {
397
475
  `);
398
476
  }
399
477
 
478
+ const scope = it.raw?.scope;
479
+ const settingsPath = it.raw?.settingsPath;
400
480
  bodySections.push(`
401
481
  <div class="detail-section">
402
482
  <h4>Metadata</h4>
@@ -404,6 +484,8 @@ function renderDetail() {
404
484
  <span class="detail-meta-item">Kind: ${esc(it.kind)}</span>
405
485
  <span class="detail-meta-item">Source: ${esc(it.source)}</span>
406
486
  ${it.active != null ? `<span class="detail-meta-item">Active: ${it.active ? 'yes' : 'no'}</span>` : ''}
487
+ ${it.disabled ? `<span class="detail-meta-item">Disabled${scope ? ` (${esc(scope)})` : ''}</span>` : ''}
488
+ ${settingsPath ? `<span class="detail-meta-item">Settings: ${esc(settingsPath)}</span>` : ''}
407
489
  </div>
408
490
  </div>
409
491
  `);
@@ -424,12 +506,14 @@ function renderDetail() {
424
506
  `);
425
507
  }
426
508
 
509
+ const ghUrl = githubUrlFor(it);
427
510
  panel.innerHTML = `
428
511
  <div class="detail-header">
429
512
  <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
430
513
  <div class="detail-header-actions">
431
514
  ${it.path ? `<button class="detail-action" id="openEditorBtn" title="Open in $EDITOR"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>` : ''}
432
515
  ${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>` : ''}
516
+ ${ghUrl ? `<button class="detail-action" id="openGithubBtn" title="Open on GitHub"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.27-1.68-1.27-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.96 10.96 0 015.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56C20.21 21.38 23.5 17.07 23.5 12 23.5 5.65 18.35.5 12 .5z"/></svg></button>` : ''}
433
517
  <button class="detail-close" id="detailCloseBtn" title="Close">&#10005;</button>
434
518
  </div>
435
519
  </div>
@@ -462,6 +546,10 @@ function renderDetail() {
462
546
  try { await navigator.clipboard.writeText(it.path); toast('Path copied'); }
463
547
  catch { toast('Copy failed'); }
464
548
  });
549
+ const ghBtn = $('openGithubBtn');
550
+ if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
551
+ window.open(ghUrl, '_blank', 'noopener');
552
+ });
465
553
  }
466
554
  //#endregion
467
555
 
@@ -530,6 +618,33 @@ function bindEvents() {
530
618
  toast('refreshed');
531
619
  });
532
620
 
621
+ $('cleanupSessionsBtn').addEventListener('click', async () => {
622
+ const keep = state.currentSessionId;
623
+ if (!keep) { toast('select a session first'); return; }
624
+ const others = state.sessions.filter((s) => s.id !== keep).length;
625
+ if (!others) { toast('only one session — nothing to remove'); return; }
626
+ if (!confirm(`Delete ${others} other snapshot${others === 1 ? '' : 's'} and keep only the selected session?`)) return;
627
+ const btn = $('cleanupSessionsBtn');
628
+ btn.classList.add('loading');
629
+ try {
630
+ const r = await fetch('/api/sessions/cleanup', {
631
+ method: 'POST',
632
+ headers: { 'Content-Type': 'application/json' },
633
+ body: JSON.stringify({ keep }),
634
+ });
635
+ const data = await r.json();
636
+ if (!r.ok || data.ok === false) throw new Error(data.error || `HTTP ${r.status}`);
637
+ const n = data.removed?.length ?? 0;
638
+ await loadSessions();
639
+ renderTopbar();
640
+ toast(n ? `removed ${n} session${n === 1 ? '' : 's'}` : 'nothing to remove');
641
+ } catch (e) {
642
+ toast(`cleanup failed: ${e.message}`);
643
+ } finally {
644
+ btn.classList.remove('loading');
645
+ }
646
+ });
647
+
533
648
  $('themeBtn').addEventListener('click', () => {
534
649
  document.body.classList.toggle('light');
535
650
  try {
@@ -540,6 +655,12 @@ function bindEvents() {
540
655
  $('expandToggle').addEventListener('click', () => {
541
656
  state.expandAll = !state.expandAll;
542
657
  for (const k of KIND_ORDER) state.expanded[k] = state.expandAll;
658
+ const subKeys = new Set(Object.keys(state.expanded).filter((k) => k.includes('::')));
659
+ for (const it of buildItems()) subKeys.add(`${it.kind}::${it.source || '(unknown)'}`);
660
+ for (const key of subKeys) {
661
+ if (state.expandAll) delete state.expanded[key];
662
+ else state.expanded[key] = false;
663
+ }
543
664
  $('expandToggle').textContent = state.expandAll ? 'Collapse all' : 'Expand all';
544
665
  renderTree();
545
666
  });
@@ -551,15 +672,120 @@ function bindEvents() {
551
672
  renderDetail();
552
673
  });
553
674
 
554
- window.addEventListener('keydown', (e) => {
555
- if (e.target?.tagName === 'INPUT' || e.target?.tagName === 'SELECT') return;
556
- if (e.key === '/') { e.preventDefault(); $('searchInput').focus(); }
557
- else if (e.key === 'r' || e.key === 'R') $('refreshBtn').click();
558
- else if (e.key === 't' || e.key === 'T') $('themeBtn').click();
559
- else if (e.key === 'Escape') { state.selected = null; renderTree(); renderDetail(); }
675
+ window.addEventListener('keydown', handleKeydown);
676
+
677
+ document.addEventListener('selectionchange', () => {
678
+ const sel = document.getSelection();
679
+ if (!sel || sel.isCollapsed) return;
680
+ const node = sel.anchorNode;
681
+ const host = node?.nodeType === 1 ? node : node?.parentElement;
682
+ const row = host?.closest?.('.tree-row[data-item]');
683
+ if (!row) return;
684
+ const id = row.dataset.item;
685
+ const root = $('treeContainer');
686
+ const idx = Array.prototype.indexOf.call(root.children, row);
687
+ if (idx >= 0) state.highlight = idx;
688
+ if (state.selected !== id) {
689
+ state.selected = id;
690
+ renderTree();
691
+ renderDetail();
692
+ }
693
+ row.scrollIntoView({ block: 'nearest' });
694
+ });
695
+
696
+ $('shortcutsBtn').addEventListener('click', showHelpModal);
697
+ $('helpCloseBtn').addEventListener('click', hideHelpModal);
698
+ $('helpModal').addEventListener('click', (e) => {
699
+ if (e.target === $('helpModal')) hideHelpModal();
560
700
  });
561
701
  }
562
702
 
703
+ function moveHighlight(delta) {
704
+ const rows = state.visibleRows;
705
+ if (!rows.length) return;
706
+ const prev = state.highlight;
707
+ let idx = prev;
708
+ if (idx < 0) {
709
+ const selIdx = state.selected ? rows.findIndex((r) => r.type === 'item' && r.key === state.selected) : -1;
710
+ if (selIdx >= 0) idx = Math.max(0, Math.min(rows.length - 1, selIdx + delta));
711
+ else idx = delta > 0 ? 0 : rows.length - 1;
712
+ } else idx = Math.max(0, Math.min(rows.length - 1, idx + delta));
713
+ if (idx === prev) return;
714
+ state.highlight = idx;
715
+ const root = $('treeContainer');
716
+ if (prev >= 0) root.children[prev]?.classList.remove('focused');
717
+ const el = root.children[idx];
718
+ if (el) {
719
+ el.classList.add('focused');
720
+ el.scrollIntoView({ block: 'nearest' });
721
+ }
722
+ }
723
+
724
+ function activateHighlight() {
725
+ const r = state.visibleRows[state.highlight];
726
+ if (!r) return;
727
+ const root = $('treeContainer');
728
+ const el = root.children[state.highlight];
729
+ if (el) el.click();
730
+ }
731
+
732
+ function expandAtHighlight(open) {
733
+ const r = state.visibleRows[state.highlight];
734
+ if (!r) return;
735
+ if (r.type === 'group') {
736
+ state.expanded[r.key] = open;
737
+ renderTree();
738
+ } else if (r.type === 'subgroup') {
739
+ if (open) delete state.expanded[r.key];
740
+ else state.expanded[r.key] = false;
741
+ renderTree();
742
+ } else if (r.type === 'item' && !open) {
743
+ for (let i = state.highlight - 1; i >= 0; i--) {
744
+ if (state.visibleRows[i].type === 'group' || state.visibleRows[i].type === 'subgroup') {
745
+ state.highlight = i;
746
+ renderTree();
747
+ break;
748
+ }
749
+ }
750
+ }
751
+ }
752
+
753
+ function showHelpModal() { $('helpModal').classList.add('open'); }
754
+ function hideHelpModal() { $('helpModal').classList.remove('open'); }
755
+ function isHelpOpen() { return $('helpModal')?.classList.contains('open'); }
756
+
757
+ function handleKeydown(e) {
758
+ if (isHelpOpen()) {
759
+ if (e.key === 'Escape' || e.key === '?') {
760
+ e.preventDefault();
761
+ hideHelpModal();
762
+ }
763
+ return;
764
+ }
765
+ const tag = e.target?.tagName;
766
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
767
+ if (e.key === 'Escape') {
768
+ e.target.blur();
769
+ e.preventDefault();
770
+ }
771
+ return;
772
+ }
773
+ if (e.key === '?') { e.preventDefault(); showHelpModal(); return; }
774
+ if (matchKey(e, '/')) { e.preventDefault(); $('searchInput').focus(); return; }
775
+ if (matchKey(e, 'f', 'F')) { e.preventDefault(); $('kindFilter').focus(); return; }
776
+ if (matchKey(e, 'r', 'R')) { e.preventDefault(); $('refreshBtn').click(); return; }
777
+ if (matchKey(e, 't', 'T')) { e.preventDefault(); $('themeBtn').click(); return; }
778
+ if (matchKey(e, 'e', 'E')) { e.preventDefault(); $('expandToggle').click(); return; }
779
+ if (matchKey(e, 'j', 'ArrowDown')) { e.preventDefault(); moveHighlight(1); return; }
780
+ if (matchKey(e, 'k', 'ArrowUp')) { e.preventDefault(); moveHighlight(-1); return; }
781
+ if (matchKey(e, 'l', 'ArrowRight')) { e.preventDefault(); expandAtHighlight(true); return; }
782
+ if (matchKey(e, 'h', 'ArrowLeft')) { e.preventDefault(); expandAtHighlight(false); return; }
783
+ if (matchKey(e, 'Enter', ' ', 'Space')) { e.preventDefault(); activateHighlight(); return; }
784
+ if (e.key === 'Escape') {
785
+ if (state.selected) { state.selected = null; renderTree(); renderDetail(); }
786
+ }
787
+ }
788
+
563
789
  function bindSse() {
564
790
  const es = new EventSource('/api/events');
565
791
  es.addEventListener('snapshot', async () => {
package/public/index.html CHANGED
@@ -31,9 +31,13 @@
31
31
  <option value="context">Context</option>
32
32
  <option value="tool">Tools</option>
33
33
  <option value="command">Commands</option>
34
+ <option value="prompt">Prompts</option>
34
35
  <option value="skill">Skills</option>
35
36
  </select>
36
37
  <select class="topbar-select" id="sessionSelect" title="Switch session"></select>
38
+ <button class="topbar-btn" id="cleanupSessionsBtn" title="Delete all snapshots except the selected session">
39
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 01-2 2H9a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a2 2 0 012-2h2a2 2 0 012 2v2"/></svg>
40
+ </button>
37
41
  <div class="topbar-project" id="projectBtn" title="Current cwd">
38
42
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
39
43
  <span id="projectPath">—</span>
@@ -45,6 +49,13 @@
45
49
  <button class="topbar-btn" id="themeBtn" title="Toggle theme">
46
50
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
47
51
  </button>
52
+ <button class="topbar-btn" id="shortcutsBtn" title="Keyboard shortcuts (?)">
53
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
54
+ <kbd style="font-size:10px">?</kbd>
55
+ </button>
56
+ <a class="topbar-btn" href="https://github.com/NikiforovAll/pi-inspect" target="_blank" rel="noopener" title="GitHub">
57
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
58
+ </a>
48
59
  </div>
49
60
 
50
61
  <div class="main-layout">
@@ -65,6 +76,41 @@
65
76
 
66
77
  <div class="toast-container" id="toast"></div>
67
78
 
79
+ <div class="modal-overlay" id="helpModal" aria-hidden="true">
80
+ <div class="modal help-modal" role="dialog" aria-label="Keyboard shortcuts">
81
+ <div class="modal-header">
82
+ <h3>Keyboard Shortcuts</h3>
83
+ <button class="modal-close" id="helpCloseBtn" title="Close (Esc)">&#10005;</button>
84
+ </div>
85
+ <div class="modal-body">
86
+ <div class="help-sections">
87
+ <div class="help-section">
88
+ <h4>Global</h4>
89
+ <table>
90
+ <tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
91
+ <tr><td><kbd>/</kbd></td><td>Focus search</td></tr>
92
+ <tr><td><kbd>f</kbd></td><td>Focus kind filter</td></tr>
93
+ <tr><td><kbd>r</kbd></td><td>Refresh</td></tr>
94
+ <tr><td><kbd>t</kbd></td><td>Toggle theme</td></tr>
95
+ <tr><td><kbd>e</kbd></td><td>Expand / collapse all</td></tr>
96
+ <tr><td><kbd>Esc</kbd></td><td>Close modal / clear selection / blur input</td></tr>
97
+ </table>
98
+ </div>
99
+ <div class="help-section">
100
+ <h4>Navigation</h4>
101
+ <table>
102
+ <tr><td><kbd>j</kbd> / <kbd>↓</kbd></td><td>Next row</td></tr>
103
+ <tr><td><kbd>k</kbd> / <kbd>↑</kbd></td><td>Previous row</td></tr>
104
+ <tr><td><kbd>l</kbd> / <kbd>→</kbd></td><td>Expand group</td></tr>
105
+ <tr><td><kbd>h</kbd> / <kbd>←</kbd></td><td>Collapse group / go to parent</td></tr>
106
+ <tr><td><kbd>Enter</kbd> / <kbd>Space</kbd></td><td>Select / toggle</td></tr>
107
+ </table>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
68
114
  <script src="app.js"></script>
69
115
  <script>
70
116
  if ('serviceWorker' in navigator) {
package/public/style.css CHANGED
@@ -359,15 +359,30 @@ body {
359
359
  border-bottom: 1px solid var(--border);
360
360
  position: sticky;
361
361
  top: 0;
362
- z-index: 2;
362
+ z-index: 3;
363
363
  font-size: 12px;
364
364
  text-transform: uppercase;
365
365
  letter-spacing: 0.04em;
366
366
  padding: 8px 12px;
367
367
  }
368
+ /* Stacks under the group row (top:0); 38px matches .tree-row min-height. */
369
+ .tree-row.marketplace-row.tree-subgroup {
370
+ top: 38px;
371
+ z-index: 2;
372
+ }
368
373
  .tree-row.marketplace-row.virtual .tree-icon {
369
374
  color: var(--plan);
370
375
  }
376
+ .tree-row.disabled .tree-label,
377
+ .tree-row.disabled .tree-desc,
378
+ .tree-row.disabled .tree-icon {
379
+ opacity: 0.45;
380
+ }
381
+ .tree-row.disabled .tree-meta {
382
+ color: var(--text-muted, #888);
383
+ font-style: italic;
384
+ opacity: 0.8;
385
+ }
371
386
 
372
387
  .tree-indent {
373
388
  flex-shrink: 0;
@@ -433,10 +448,22 @@ body {
433
448
  .tree-subgroup .tree-icon { width: 14px; height: 14px; }
434
449
  .tree-subgroup .tree-icon svg { width: 14px; height: 14px; }
435
450
  .tree-meta {
436
- font-size: 10px;
437
- color: var(--text-muted);
451
+ font-size: 11px;
452
+ color: var(--text, inherit);
438
453
  flex-shrink: 0;
439
454
  white-space: nowrap;
455
+ opacity: 0.65;
456
+ }
457
+ .tree-desc {
458
+ font-size: 11px;
459
+ color: var(--text-muted);
460
+ margin-left: 12px;
461
+ white-space: nowrap;
462
+ overflow: hidden;
463
+ text-overflow: ellipsis;
464
+ flex: 9 1 0;
465
+ min-width: 0;
466
+ opacity: 0.75;
440
467
  }
441
468
 
442
469
  /* === SCOPE TOGGLE BOXES === */
package/server.js CHANGED
@@ -7,6 +7,7 @@ const os = require('node:os');
7
7
  const chokidar = require('chokidar');
8
8
  const open = require('open').default || require('open');
9
9
  const snapshots = require('./lib/snapshot');
10
+ const githubSource = require('./lib/github-source');
10
11
  const pkg = require('./package.json');
11
12
 
12
13
  const PORT = Number(process.env.PORT) || 5462;
@@ -39,12 +40,43 @@ app.get('/api/sessions', async (_req, res) => {
39
40
  }
40
41
  });
41
42
 
43
+ app.post('/api/sessions/cleanup', async (req, res) => {
44
+ try {
45
+ const keep = (req.body && req.body.keep) || req.query.keep || null;
46
+ const result = keep
47
+ ? await snapshots.keepOnly(String(keep))
48
+ : await snapshots.cleanupIndex();
49
+ res.json({ ok: true, ...result });
50
+ } catch (e) {
51
+ res.status(500).json({ ok: false, error: e.message });
52
+ }
53
+ });
54
+
55
+ const githubMemo = new Map(); // sessionId -> Record<root, {url, source}>
56
+
57
+ function collectSourceRoots(snap) {
58
+ const roots = new Set();
59
+ const add = (x) => {
60
+ const baseDir = x?.sourceInfo?.baseDir;
61
+ if (typeof baseDir === 'string' && baseDir) roots.add(baseDir);
62
+ };
63
+ for (const t of snap.tools ?? []) add(t);
64
+ for (const c of snap.commands ?? []) add(c);
65
+ return [...roots];
66
+ }
67
+
42
68
  app.get('/api/introspect', async (req, res) => {
43
69
  try {
44
70
  const sid = req.query.session ? String(req.query.session) : null;
45
71
  const snap = sid ? await snapshots.readSnapshot(sid) : await snapshots.readLatestSnapshot();
46
72
  if (!snap) return res.status(404).json({ error: 'no snapshot found', sessionId: sid });
47
- res.json(snap);
73
+ const memoKey = snap.sessionId;
74
+ let githubSources = memoKey ? githubMemo.get(memoKey) : null;
75
+ if (!githubSources) {
76
+ githubSources = await githubSource.resolveMany(collectSourceRoots(snap));
77
+ if (memoKey) githubMemo.set(memoKey, githubSources);
78
+ }
79
+ res.json({ ...snap, githubSources });
48
80
  } catch (e) {
49
81
  res.status(500).json({ error: e.message });
50
82
  }