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 +4 -1
- package/extensions/inspect.ts +159 -3
- package/lib/github-source.js +105 -0
- package/lib/snapshot.js +46 -0
- package/package.json +1 -1
- package/public/app.js +251 -25
- package/public/index.html +46 -0
- package/public/style.css +30 -3
- package/server.js +33 -1
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
|
-
|
|
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
|
|
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 });
|
|
@@ -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
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)
|
|
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
|
|
137
|
+
id,
|
|
115
138
|
name: t.name ?? '(tool)',
|
|
116
139
|
source: inferSource(t),
|
|
117
|
-
description
|
|
118
|
-
|
|
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
|
|
128
|
-
id
|
|
157
|
+
kind,
|
|
158
|
+
id,
|
|
129
159
|
name: `/${name}`,
|
|
130
|
-
source:
|
|
131
|
-
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.
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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">✕</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',
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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)">✕</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:
|
|
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:
|
|
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;
|
|
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
|
-
|
|
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
|
}
|