pi-inspect 0.2.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/extensions/inspect.ts +159 -3
- package/lib/snapshot.js +46 -0
- package/package.json +1 -1
- package/public/app.js +78 -15
- package/public/index.html +4 -0
- package/public/style.css +19 -3
- package/server.js +12 -0
package/extensions/inspect.ts
CHANGED
|
@@ -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
|
|
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 });
|
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
package/public/app.js
CHANGED
|
@@ -5,7 +5,7 @@ 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
11
|
highlight: -1,
|
|
@@ -110,7 +110,11 @@ function inferSource(x) {
|
|
|
110
110
|
const si = x?.sourceInfo;
|
|
111
111
|
if (si) {
|
|
112
112
|
if (si.label) return si.label;
|
|
113
|
-
if (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
|
+
}
|
|
114
118
|
if (si.origin) return si.origin;
|
|
115
119
|
if (si.kind) return si.kind;
|
|
116
120
|
}
|
|
@@ -122,16 +126,20 @@ function buildItems() {
|
|
|
122
126
|
const s = state.snapshot;
|
|
123
127
|
if (!s) return [];
|
|
124
128
|
const items = [];
|
|
129
|
+
const activeSet = new Set(s.activeTools ?? []);
|
|
130
|
+
const activeIds = new Set();
|
|
125
131
|
for (const t of s.tools ?? []) {
|
|
126
132
|
const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
|
|
133
|
+
const id = `tool:${t.name}`;
|
|
134
|
+
activeIds.add(id);
|
|
127
135
|
items.push({
|
|
128
136
|
kind: 'tool',
|
|
129
|
-
id
|
|
137
|
+
id,
|
|
130
138
|
name: t.name ?? '(tool)',
|
|
131
139
|
source: inferSource(t),
|
|
132
140
|
description,
|
|
133
141
|
chars: (t.description ?? '').length,
|
|
134
|
-
active:
|
|
142
|
+
active: activeSet.has(t.name),
|
|
135
143
|
path: inferPath(t),
|
|
136
144
|
raw: t,
|
|
137
145
|
});
|
|
@@ -139,18 +147,39 @@ function buildItems() {
|
|
|
139
147
|
for (const c of s.commands ?? []) {
|
|
140
148
|
const name = c.name ?? c.command ?? '';
|
|
141
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';
|
|
142
153
|
const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
|
|
154
|
+
const id = `${kind}:${name}`;
|
|
155
|
+
activeIds.add(id);
|
|
143
156
|
items.push({
|
|
144
|
-
kind
|
|
145
|
-
id
|
|
157
|
+
kind,
|
|
158
|
+
id,
|
|
146
159
|
name: `/${name}`,
|
|
147
|
-
source:
|
|
160
|
+
source: src,
|
|
148
161
|
description,
|
|
149
162
|
chars: (c.description ?? '').length,
|
|
150
163
|
path: inferPath(c),
|
|
151
164
|
raw: c,
|
|
152
165
|
});
|
|
153
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
|
+
}
|
|
154
183
|
if (s.systemPrompt) {
|
|
155
184
|
for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
|
|
156
185
|
items.push({
|
|
@@ -189,8 +218,9 @@ function filterItems(items) {
|
|
|
189
218
|
});
|
|
190
219
|
}
|
|
191
220
|
|
|
192
|
-
const KIND_ORDER = ['context', 'tool', 'command', 'skill'];
|
|
193
|
-
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 };
|
|
194
224
|
//#endregion
|
|
195
225
|
|
|
196
226
|
//#region ICONS
|
|
@@ -201,6 +231,9 @@ function iconFor(kind) {
|
|
|
201
231
|
if (kind === 'command') {
|
|
202
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>`;
|
|
203
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
|
+
}
|
|
204
237
|
if (kind === 'skill') {
|
|
205
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>`;
|
|
206
239
|
}
|
|
@@ -334,11 +367,9 @@ function renderTree() {
|
|
|
334
367
|
const useSubgroups = bySource.size > 1 && kind !== 'context';
|
|
335
368
|
const sources = useSubgroups
|
|
336
369
|
? [...bySource.keys()].sort((a, b) => {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
};
|
|
341
|
-
return (rank(a) - rank(b)) || a.localeCompare(b);
|
|
370
|
+
const ra = SOURCE_RANK[a] ?? Infinity;
|
|
371
|
+
const rb = SOURCE_RANK[b] ?? Infinity;
|
|
372
|
+
return (ra - rb) || a.localeCompare(b);
|
|
342
373
|
})
|
|
343
374
|
: ['__all__'];
|
|
344
375
|
if (!useSubgroups) bySource.set('__all__', list);
|
|
@@ -367,8 +398,9 @@ function renderTree() {
|
|
|
367
398
|
const descHtml = it.description
|
|
368
399
|
? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
|
|
369
400
|
: '<div class="spacer"></div>';
|
|
401
|
+
const disabledCls = it.disabled ? ' disabled' : '';
|
|
370
402
|
html.push(`
|
|
371
|
-
<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">
|
|
372
404
|
<div class="tree-icon">${iconFor(it.kind)}</div>
|
|
373
405
|
<div class="tree-label">${esc(it.name)}</div>
|
|
374
406
|
${descHtml}
|
|
@@ -443,6 +475,8 @@ function renderDetail() {
|
|
|
443
475
|
`);
|
|
444
476
|
}
|
|
445
477
|
|
|
478
|
+
const scope = it.raw?.scope;
|
|
479
|
+
const settingsPath = it.raw?.settingsPath;
|
|
446
480
|
bodySections.push(`
|
|
447
481
|
<div class="detail-section">
|
|
448
482
|
<h4>Metadata</h4>
|
|
@@ -450,6 +484,8 @@ function renderDetail() {
|
|
|
450
484
|
<span class="detail-meta-item">Kind: ${esc(it.kind)}</span>
|
|
451
485
|
<span class="detail-meta-item">Source: ${esc(it.source)}</span>
|
|
452
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>` : ''}
|
|
453
489
|
</div>
|
|
454
490
|
</div>
|
|
455
491
|
`);
|
|
@@ -582,6 +618,33 @@ function bindEvents() {
|
|
|
582
618
|
toast('refreshed');
|
|
583
619
|
});
|
|
584
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
|
+
|
|
585
648
|
$('themeBtn').addEventListener('click', () => {
|
|
586
649
|
document.body.classList.toggle('light');
|
|
587
650
|
try {
|
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>
|
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:
|
|
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,11 @@ 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:
|
|
437
|
-
color: var(--text
|
|
451
|
+
font-size: 11px;
|
|
452
|
+
color: var(--text, inherit);
|
|
438
453
|
flex-shrink: 0;
|
|
439
454
|
white-space: nowrap;
|
|
455
|
+
opacity: 0.65;
|
|
440
456
|
}
|
|
441
457
|
.tree-desc {
|
|
442
458
|
font-size: 11px;
|
package/server.js
CHANGED
|
@@ -40,6 +40,18 @@ app.get('/api/sessions', async (_req, res) => {
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
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
|
+
|
|
43
55
|
const githubMemo = new Map(); // sessionId -> Record<root, {url, source}>
|
|
44
56
|
|
|
45
57
|
function collectSourceRoots(snap) {
|