pi-weave 0.1.1

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.
@@ -0,0 +1,1011 @@
1
+ /**
2
+ * PURE view-model for the weave-view TUI (weave-view-tui-design §5, §10.1).
3
+ *
4
+ * Ports the browser page's `listTree` / `focusNeighborhood` / `deriveBacklinks`
5
+ * semantics into TypeScript. Imports ONLY core types — no pi-tui imports —
6
+ * so the bulk of the logic is unit-tested like the page's listTree tests.
7
+ *
8
+ * Every interesting behavior is an exported pure function: `treeRows`,
9
+ * `focusModel`, `detailModel`, `healthModel`, `reduce`, `sanitizeTerminalText`.
10
+ */
11
+
12
+ import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../../../core/graph/model";
13
+ import type { NoteSource } from "../../../core/types";
14
+ import { MAX_FILTER_LEN, PROVENANCE_CYCLE, provenanceStyle, type ProvenanceStyle } from "./theme";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Row types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** A selectable row in any surface. `target` (when set) is the node id a row
21
+ * jumps to on `enter` (a link / neighbor). */
22
+ export interface SelectableRow {
23
+ id: string;
24
+ /** Node id this row jumps to on enter (links/backlinks/neighbors). */
25
+ target?: string;
26
+ }
27
+
28
+ /** One row in the Explore tree. */
29
+ export interface TreeRow extends SelectableRow {
30
+ depth: number;
31
+ hasKids: boolean;
32
+ expanded: boolean;
33
+ label: string;
34
+ kind: NodeKind;
35
+ provenance: NoteSource | null;
36
+ /** Dim meta tail (note: relative updated; module: files=N; …), pre-truncated. */
37
+ meta: string;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Shared pure helpers (mirror the page's `focusNeighborhood` / `deriveBacklinks`)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** 1-hop neighborhood of `id` (the node itself + direct neighbors). 2-hop excluded. */
45
+ export function focusNeighborhood(id: string, edges: readonly GraphEdge[]): Set<string> {
46
+ const out = new Set<string>([id]);
47
+ for (const e of edges) {
48
+ if (e.source === id) out.add(e.target);
49
+ if (e.target === id) out.add(e.source);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Map each node id → its incoming `links-to` sources (backlinks). */
55
+ export function deriveBacklinks(edges: readonly GraphEdge[]): Map<string, string[]> {
56
+ const out = new Map<string, string[]>();
57
+ for (const e of edges) {
58
+ if (e.kind !== "links-to") continue;
59
+ const list = out.get(e.target);
60
+ if (list) list.push(e.source);
61
+ else out.set(e.target, [e.source]);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Incident-edge degree (both directions, all kinds). */
67
+ export function degreeOf(id: string, edges: readonly GraphEdge[]): number {
68
+ let d = 0;
69
+ for (const e of edges) if (e.source === id || e.target === id) d++;
70
+ return d;
71
+ }
72
+
73
+ /** Relative human time, mirroring the page's `relTime`. `now` is epoch ms. */
74
+ export function relTime(iso: string | undefined, now: number): string {
75
+ if (!iso) return "";
76
+ const t = Date.parse(iso);
77
+ if (Number.isNaN(t)) return "";
78
+ const s = Math.max(0, Math.floor((now - t) / 1000));
79
+ if (s < 60) return "just now";
80
+ const m = Math.floor(s / 60);
81
+ if (m < 60) return `${m}m ago`;
82
+ const h = Math.floor(m / 60);
83
+ if (h < 24) return `${h}h ago`;
84
+ const d = Math.floor(h / 24);
85
+ if (d < 30) return `${d}d ago`;
86
+ const mo = Math.floor(d / 30);
87
+ if (mo < 12) return `${mo}mo ago`;
88
+ return `${Math.floor(mo / 12)}y ago`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // treeRows — the Explore surface (port of the page's listTree, 1:1)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export interface TreeState {
96
+ /** Expanded node ids (when no filter is active). */
97
+ expanded: ReadonlySet<string>;
98
+ /** Reveal repo plumbing (gitState/external/package/entryPoint). */
99
+ showInternals: boolean;
100
+ /** Provenance filter (null = all). */
101
+ provFilter: NoteSource | null;
102
+ /** Substring filter on node labels (case-insensitive). */
103
+ query: string;
104
+ /** Reference time for relative-time meta (epoch ms). */
105
+ now: number;
106
+ }
107
+
108
+ /** Disambiguate labels that would otherwise read as the same entry twice
109
+ * (a remote URL whose tail matches the repo name, an npm package named
110
+ * after the repo). Mirrors the page's `listLabel`. */
111
+ export function listLabel(node: GraphNode): string {
112
+ if (node.kind === "external" && node.detail.url) {
113
+ const u = node.detail.url
114
+ .replace(/^[a-z]+:\/\//, "")
115
+ .replace(/^[^@\/]+@/, "")
116
+ .replace(/\.git$/, "")
117
+ .replace(/\/+$/, "");
118
+ if (u) return u;
119
+ }
120
+ if (node.kind === "package" && node.detail.manifest) {
121
+ return `${node.label} (${node.detail.manifest})`;
122
+ }
123
+ return node.label;
124
+ }
125
+
126
+ /** Build the contains/anchored-at nesting + entry-point-to-module map. */
127
+ interface TreeIndex {
128
+ byId: Map<string, GraphNode>;
129
+ /** strict contains (used for entry-point → module placement). */
130
+ contains: Map<string, string[]>;
131
+ /** contains + anchored-at (the nesting hierarchy). */
132
+ tree: Map<string, string[]>;
133
+ /** node id → true when it has an incoming contains/anchored-at edge. */
134
+ incoming: Set<string>;
135
+ /** module id → entry-point ids nested under it (by path prefix). */
136
+ moduleEntries: Map<string, string[]>;
137
+ /** roots (nodes with no incoming edge), in node-input order. */
138
+ roots: string[];
139
+ }
140
+
141
+ function indexTree(model: GraphModel): TreeIndex {
142
+ const byId = new Map<string, GraphNode>();
143
+ for (const n of model.nodes) byId.set(n.id, n);
144
+
145
+ const contains = new Map<string, string[]>();
146
+ const tree = new Map<string, string[]>();
147
+ const incoming = new Set<string>();
148
+ for (const e of model.edges) {
149
+ if (e.kind === "contains") {
150
+ const list = contains.get(e.source);
151
+ if (list) list.push(e.target);
152
+ else contains.set(e.source, [e.target]);
153
+ }
154
+ if (e.kind === "contains" || e.kind === "anchored-at") {
155
+ const list = tree.get(e.source);
156
+ if (list) list.push(e.target);
157
+ else tree.set(e.source, [e.target]);
158
+ incoming.add(e.target);
159
+ }
160
+ }
161
+
162
+ // Entry points nest under the module whose path is the entry's directory prefix.
163
+ // The caller only invokes this for entryPoint nodes, so the path guard suffices.
164
+ const moduleFor = (entry: GraphNode): string | null => {
165
+ if (!entry.detail.path) return null;
166
+ const p = entry.detail.path;
167
+ let best: string | null = null;
168
+ let bestLen = 0;
169
+ const repoKids = contains.get("repository") ?? [];
170
+ for (const cid of repoKids) {
171
+ const c = byId.get(cid);
172
+ if (c && c.kind === "module" && c.detail.path) {
173
+ const mp = c.detail.path;
174
+ if (p.startsWith(mp + "/") && mp.length > bestLen) {
175
+ best = cid;
176
+ bestLen = mp.length;
177
+ }
178
+ }
179
+ }
180
+ return best;
181
+ };
182
+
183
+ const moduleEntries = new Map<string, string[]>();
184
+ for (const n of model.nodes) {
185
+ if (n.kind !== "entryPoint") continue;
186
+ const m = moduleFor(n);
187
+ if (m) {
188
+ const list = moduleEntries.get(m);
189
+ if (list) list.push(n.id);
190
+ else moduleEntries.set(m, [n.id]);
191
+ }
192
+ }
193
+
194
+ const roots = model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
195
+ return { byId, contains, tree, incoming, moduleEntries, roots };
196
+ }
197
+
198
+ /** Knowledge-first default: hide repo plumbing unless showInternals. */
199
+ function hiddenInternalKind(kind: NodeKind): boolean {
200
+ return kind === "gitState" || kind === "external" || kind === "package" || kind === "entryPoint";
201
+ }
202
+
203
+ function sortKids(ids: string[], byId: Map<string, GraphNode>): string[] {
204
+ return ids
205
+ .slice()
206
+ .sort((a, b) => {
207
+ const la = listLabel(byId.get(a)!);
208
+ const lb = listLabel(byId.get(b)!);
209
+ return la.localeCompare(lb);
210
+ });
211
+ }
212
+
213
+ function metaFor(node: GraphNode, now: number): string {
214
+ switch (node.kind) {
215
+ case "note":
216
+ return node.detail.updated ? relTime(node.detail.updated, now) : "";
217
+ case "module":
218
+ return node.detail.files ? `files=${node.detail.files}` : "";
219
+ case "gitState":
220
+ return node.detail.commit ? node.detail.commit.slice(0, 7) : "";
221
+ case "repository":
222
+ return node.detail.files ? `${node.detail.files} files` : "";
223
+ case "package":
224
+ return node.detail.kind ?? "";
225
+ case "entryPoint":
226
+ return node.detail.summary ? "summary" : "";
227
+ default:
228
+ return "";
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Build the Explore tree rows (port of the page's `listTree`).
234
+ *
235
+ * - Roots: `vault`, `repository` (nodes with no incoming containment edge).
236
+ * - Entry points nest under their prefix module.
237
+ * - Internals hidden by default; `i` toggles.
238
+ * - Filter (query and/or provenance): prune to matches + ancestors, auto-expanding ancestors.
239
+ * - Default expansion (no filter): controlled by `state.expanded`.
240
+ */
241
+ export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
242
+ const idx = indexTree(model);
243
+ const { byId, tree, moduleEntries, roots } = idx;
244
+
245
+ const children = (id: string): string[] => {
246
+ const kids = tree.get(id) ?? [];
247
+ // Drop entry points that have a claiming module (they re-nest under it).
248
+ const filtered = kids.filter((kid) => {
249
+ const n = byId.get(kid);
250
+ if (!n || n.kind !== "entryPoint") return true;
251
+ for (const [, entries] of moduleEntries) {
252
+ if (entries.includes(kid)) return false;
253
+ }
254
+ return true;
255
+ });
256
+ let all = filtered.concat(moduleEntries.get(id) ?? []);
257
+ // Skip kids absent from the node set (corrupt/hand-built graphs): the page
258
+ // never produces these, but a defensive filter keeps the tree robust.
259
+ all = all.filter((k) => byId.has(k));
260
+ if (!state.showInternals) {
261
+ all = all.filter((k) => {
262
+ const n = byId.get(k);
263
+ return !(n && hiddenInternalKind(n.kind));
264
+ });
265
+ }
266
+ return sortKids(all, byId);
267
+ };
268
+
269
+ const filtering = state.provFilter !== null || state.query.length > 0;
270
+
271
+ const matches = (node: GraphNode): boolean => {
272
+ if (state.provFilter !== null && node.provenance !== state.provFilter) return false;
273
+ if (state.query.length > 0 && !listLabel(node).toLowerCase().includes(state.query.toLowerCase())) return false;
274
+ return true;
275
+ };
276
+
277
+ // Pass 1: mark visible nodes (self-match or a visible descendant).
278
+ const visible = new Set<string>();
279
+ const mark = (id: string): boolean => {
280
+ const node = byId.get(id)!;
281
+ let any = false;
282
+ for (const k of children(id)) if (mark(k)) any = true;
283
+ const show = matches(node) || any;
284
+ if (show) visible.add(id);
285
+ return show;
286
+ };
287
+ for (const r of roots) mark(r);
288
+
289
+ const rows: TreeRow[] = [];
290
+ const walk = (id: string, depth: number): void => {
291
+ if (!visible.has(id)) return;
292
+ const node = byId.get(id)!;
293
+ const kids = children(id);
294
+ const visibleKids = kids.filter((k) => visible.has(k));
295
+ const expanded = filtering ? visibleKids.length > 0 : state.expanded.has(id);
296
+ rows.push({
297
+ id,
298
+ depth,
299
+ hasKids: kids.length > 0,
300
+ expanded,
301
+ label: listLabel(node),
302
+ kind: node.kind,
303
+ provenance: node.provenance,
304
+ meta: metaFor(node, state.now),
305
+ });
306
+ if (expanded) for (const k of visibleKids) walk(k, depth + 1);
307
+ };
308
+ for (const r of roots) walk(r, 0);
309
+ return rows;
310
+ }
311
+
312
+ /** Empty-state hint rows for the tree surface (design §5.1). */
313
+ export function treeEmptyHint(model: GraphModel): string | null {
314
+ const vault = model.nodes.find((n) => n.kind === "vault");
315
+ if (!vault) return null;
316
+ // no notes and no repository
317
+ const hasNotes = model.nodes.some((n) => n.kind === "note");
318
+ const hasRepo = model.nodes.some((n) => n.kind === "repository");
319
+ if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
320
+ return null;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // focusModel — the 1-hop neighborhood (port of focusNeighborhood, grouped)
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export interface FocusRow extends SelectableRow {
328
+ label: string;
329
+ kind: NodeKind;
330
+ provenance: NoteSource | null;
331
+ }
332
+
333
+ export interface FocusGroup {
334
+ /** Heading label, e.g. "links to →", "← linked from", "contains", "contained by". */
335
+ heading: string;
336
+ rows: FocusRow[];
337
+ }
338
+
339
+ export interface FocusModel {
340
+ center: FocusRow;
341
+ groups: FocusGroup[];
342
+ }
343
+
344
+ const OUTGOING_HEADINGS: Record<string, string> = {
345
+ "links-to": "links to →",
346
+ contains: "contains",
347
+ "anchored-at": "anchored at",
348
+ };
349
+
350
+ /** Group the 1-hop neighborhood of `id` (outgoing by kind, then incoming). */
351
+ export function focusModel(model: GraphModel, id: string): FocusModel {
352
+ const byId = new Map<string, GraphNode>();
353
+ for (const n of model.nodes) byId.set(n.id, n);
354
+
355
+ const centerNode = byId.get(id);
356
+ const center: FocusRow = centerNode
357
+ ? rowFromNode(centerNode)
358
+ : { id, label: id, kind: "note", provenance: null };
359
+
360
+ const outByKind = new Map<string, string[]>();
361
+ const incomingContains = new Map<string, string[]>();
362
+ const backlinks = new Map<string, string[]>();
363
+ for (const e of model.edges) {
364
+ if (e.source === id) {
365
+ const list = outByKind.get(e.kind);
366
+ if (list) list.push(e.target);
367
+ else outByKind.set(e.kind, [e.target]);
368
+ } else if (e.target === id) {
369
+ if (e.kind === "links-to") {
370
+ const list = backlinks.get(e.kind);
371
+ if (list) list.push(e.source);
372
+ else backlinks.set(e.kind, [e.source]);
373
+ } else if (e.kind === "contains" || e.kind === "anchored-at") {
374
+ const list = incomingContains.get(e.kind);
375
+ if (list) list.push(e.source);
376
+ else incomingContains.set(e.kind, [e.source]);
377
+ }
378
+ }
379
+ }
380
+
381
+ const toRows = (ids: string[]): FocusRow[] =>
382
+ ids
383
+ .map((x) => byId.get(x))
384
+ .filter((n): n is GraphNode => n !== undefined)
385
+ .map(rowFromNode);
386
+
387
+ const groups: FocusGroup[] = [];
388
+ for (const kind of ["links-to", "contains", "anchored-at"] as const) {
389
+ const ids = outByKind.get(kind) ?? [];
390
+ if (ids.length === 0) continue;
391
+ groups.push({ heading: OUTGOING_HEADINGS[kind] ?? kind, rows: toRows(ids) });
392
+ }
393
+ const bl = backlinks.get("links-to") ?? [];
394
+ if (bl.length > 0) groups.push({ heading: "← linked from", rows: toRows(bl) });
395
+ const containedBy = incomingContains.get("contains") ?? incomingContains.get("anchored-at") ?? [];
396
+ if (containedBy.length > 0) groups.push({ heading: "contained by", rows: toRows(containedBy) });
397
+
398
+ return { center, groups };
399
+ }
400
+
401
+ function rowFromNode(n: GraphNode): FocusRow {
402
+ return { id: n.id, target: n.id, label: listLabel(n), kind: n.kind, provenance: n.provenance };
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // detailModel — selected node meta + links + backlinks (body loaded async)
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export interface DetailMetaRow extends SelectableRow {
410
+ label: string;
411
+ value: string;
412
+ }
413
+
414
+ export interface DetailLinkRow extends SelectableRow {
415
+ label: string;
416
+ kind: NodeKind;
417
+ provenance: NoteSource | null;
418
+ /** "link" (outgoing) or "backlink" (incoming). */
419
+ direction: "link" | "backlink";
420
+ }
421
+
422
+ export interface DetailModel {
423
+ id: string;
424
+ label: string;
425
+ kind: NodeKind;
426
+ provenance: NoteSource | null;
427
+ meta: DetailMetaRow[];
428
+ links: DetailLinkRow[];
429
+ backlinks: DetailLinkRow[];
430
+ }
431
+
432
+ /** Ordered meta keys shown in the detail header (design §5.2). */
433
+ const META_ORDER = [
434
+ "path",
435
+ "slug",
436
+ "source",
437
+ "updated",
438
+ "created",
439
+ "tags",
440
+ "files",
441
+ "languages",
442
+ "branch",
443
+ "commit",
444
+ "uncommitted changes",
445
+ "captured",
446
+ "manifest",
447
+ "kind",
448
+ "url",
449
+ "summarized files",
450
+ "summarized by",
451
+ "summarized at",
452
+ "summary",
453
+ "dangling links",
454
+ "warning",
455
+ "stale",
456
+ "preview",
457
+ "root",
458
+ "notes",
459
+ "state",
460
+ ] as const;
461
+
462
+ export function detailModel(model: GraphModel, id: string): DetailModel | null {
463
+ const byId = new Map<string, GraphNode>();
464
+ for (const n of model.nodes) byId.set(n.id, n);
465
+ const node = byId.get(id);
466
+ if (!node) return null;
467
+
468
+ const meta: DetailMetaRow[] = [];
469
+ for (const key of META_ORDER) {
470
+ const v = node.detail[key];
471
+ if (v === undefined || v === "") continue;
472
+ meta.push({ id: `meta:${key}`, label: key, value: v });
473
+ }
474
+
475
+ const links: DetailLinkRow[] = [];
476
+ for (const e of model.edges) {
477
+ if (e.source !== id) continue;
478
+ const t = byId.get(e.target);
479
+ if (!t) continue;
480
+ links.push({
481
+ id: `link:${e.kind}:${e.target}`,
482
+ target: e.target,
483
+ label: `${e.kind} → ${listLabel(t)}`,
484
+ kind: t.kind,
485
+ provenance: t.provenance,
486
+ direction: "link",
487
+ });
488
+ }
489
+
490
+ const backlinks: DetailLinkRow[] = [];
491
+ for (const e of model.edges) {
492
+ if (e.kind !== "links-to" || e.target !== id) continue;
493
+ const s = byId.get(e.source);
494
+ if (!s) continue;
495
+ backlinks.push({
496
+ id: `backlink:${e.source}`,
497
+ target: e.source,
498
+ label: `← ${listLabel(s)}`,
499
+ kind: s.kind,
500
+ provenance: s.provenance,
501
+ direction: "backlink",
502
+ });
503
+ }
504
+
505
+ return {
506
+ id,
507
+ label: listLabel(node),
508
+ kind: node.kind,
509
+ provenance: node.provenance,
510
+ meta,
511
+ links,
512
+ backlinks,
513
+ };
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // healthModel — staleness + link health, derived exclusively from GraphModel
518
+ // ---------------------------------------------------------------------------
519
+
520
+ export interface HealthRow extends SelectableRow {
521
+ text: string;
522
+ }
523
+
524
+ export interface HealthSection {
525
+ heading: string;
526
+ rows: HealthRow[];
527
+ }
528
+
529
+ export interface HealthModel {
530
+ sections: HealthSection[];
531
+ }
532
+
533
+ const HEALTH_LIST_CAP = 10;
534
+
535
+ /** Staleness + link health, derived exclusively from the GraphModel
536
+ * (design §5.4 — mirrors v2 §7: zero new server/core fields). */
537
+ export function healthModel(model: GraphModel): HealthModel {
538
+ const byId = new Map<string, GraphNode>();
539
+ for (const n of model.nodes) byId.set(n.id, n);
540
+ const sections: HealthSection[] = [];
541
+
542
+ // Repository section
543
+ const repo = byId.get("repository");
544
+ if (repo) {
545
+ const rows: HealthRow[] = [];
546
+ const staleness = model.staleness;
547
+ if (staleness) {
548
+ rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
549
+ for (let i = 0; i < staleness.reasons.length; i++) {
550
+ rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
551
+ }
552
+ }
553
+ if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
554
+ if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
555
+ const summarized = model.nodes
556
+ .filter((n) => n.kind === "module" && n.detail["summarized files"])
557
+ .reduce((acc, n) => acc + Number(n.detail["summarized files"] ?? 0), 0);
558
+ if (summarized > 0) {
559
+ rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
560
+ }
561
+ if (repo.detail.state === "stale") {
562
+ // already covered by staleness reasons; no extra row
563
+ }
564
+ sections.push({ heading: "Repository", rows });
565
+ }
566
+
567
+ // Vault section
568
+ const vault = byId.get("vault");
569
+ if (vault) {
570
+ const rows: HealthRow[] = [];
571
+ const noteCount = Number(vault.detail.notes ?? "0");
572
+ rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
573
+ const prov = countProvenance(model.nodes);
574
+ rows.push({
575
+ id: "health:vault:provenance",
576
+ text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
577
+ });
578
+ if (vault.detail.warning) {
579
+ rows.push({ id: "health:vault:warning", text: vault.detail.warning });
580
+ }
581
+ sections.push({ heading: "Vault", rows });
582
+ }
583
+
584
+ // Link health
585
+ const backlinks = deriveBacklinks(model.edges);
586
+ const orphans: GraphNode[] = [];
587
+ const dangling: { node: GraphNode; count: number }[] = [];
588
+ for (const n of model.nodes) {
589
+ if (n.kind !== "note") continue;
590
+ if (!backlinks.has(n.id)) orphans.push(n);
591
+ const dl = n.detail["dangling links"];
592
+ if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
593
+ }
594
+ const degree = new Map<string, number>();
595
+ for (const e of model.edges) {
596
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
597
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
598
+ }
599
+ const hubs = [...model.nodes]
600
+ .map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
601
+ .filter((x) => x.d > 0)
602
+ .sort((a, b) => b.d - a.d)
603
+ .slice(0, HEALTH_LIST_CAP);
604
+
605
+ const linkRows: HealthRow[] = [];
606
+ if (orphans.length > 0) {
607
+ linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
608
+ const shown = orphans.slice(0, HEALTH_LIST_CAP);
609
+ for (let i = 0; i < shown.length; i++) {
610
+ linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
611
+ }
612
+ if (orphans.length > HEALTH_LIST_CAP) {
613
+ linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
614
+ }
615
+ } else {
616
+ linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
617
+ }
618
+ if (dangling.length > 0) {
619
+ linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
620
+ const shown = dangling.slice(0, HEALTH_LIST_CAP);
621
+ for (let i = 0; i < shown.length; i++) {
622
+ linkRows.push({
623
+ id: `health:link:dangling:${shown[i]!.node.id}`,
624
+ text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
625
+ target: shown[i]!.node.id,
626
+ });
627
+ }
628
+ if (dangling.length > HEALTH_LIST_CAP) {
629
+ linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
630
+ }
631
+ }
632
+ if (hubs.length > 0) {
633
+ linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
634
+ for (let i = 0; i < hubs.length; i++) {
635
+ linkRows.push({
636
+ id: `health:link:hub:${hubs[i]!.n.id}`,
637
+ text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
638
+ target: hubs[i]!.n.id,
639
+ });
640
+ }
641
+ }
642
+ sections.push({ heading: "Link health", rows: linkRows });
643
+
644
+ return { sections };
645
+ }
646
+
647
+ export interface ProvenanceCounts {
648
+ total: number;
649
+ human: number;
650
+ agent: number;
651
+ generated: number;
652
+ structural: number;
653
+ }
654
+
655
+ export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
656
+ const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
657
+ for (const n of nodes) {
658
+ if (n.provenance === "human") c.human++;
659
+ else if (n.provenance === "agent") c.agent++;
660
+ else if (n.provenance === "generated") c.generated++;
661
+ else c.structural++;
662
+ }
663
+ return c;
664
+ }
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // sanitizeTerminalText — the TUI's XSS analog (design §9.3)
668
+ // ---------------------------------------------------------------------------
669
+
670
+ /** Strip terminal-escape/control sequences from disk/user content before
671
+ * styling or handing to Markdown: ESC (0x1b), CSI (0x9b), BEL (0x07), and
672
+ * other C0 controls except newline (0x0a) and tab (0x09). */
673
+ export function sanitizeTerminalText(s: string): string {
674
+ // eslint-disable-next-line no-control-regex
675
+ return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // ExplorerState + reduce — key→state transitions (design §6, §10.1)
680
+ // ---------------------------------------------------------------------------
681
+
682
+ export type Surface = "tree" | "detail" | "focus" | "health";
683
+
684
+ export interface ExplorerState {
685
+ surface: Surface;
686
+ /** Inline search sub-mode (only meaningful on the tree surface). */
687
+ searching: boolean;
688
+ /** Selected node id (source of truth; the cursor index is derived). */
689
+ selectedId: string | null;
690
+ /** Node pinned for focus mode. */
691
+ focusId: string | null;
692
+ /** Node open in detail. */
693
+ detailId: string | null;
694
+ /** Tree expansion (when no filter is active). */
695
+ expanded: Set<string>;
696
+ showInternals: boolean;
697
+ provFilter: NoteSource | null;
698
+ query: string;
699
+ helpOpen: boolean;
700
+ refreshing: boolean;
701
+ /** Bumped on every mutation so the render cache can key off it. */
702
+ version: number;
703
+ /** Scroll offset (top visible row index) for the current surface. */
704
+ scrollOffset: number;
705
+ }
706
+
707
+ export function initialState(roots: readonly string[]): ExplorerState {
708
+ const expanded = new Set<string>();
709
+ for (const r of roots) expanded.add(r);
710
+ const selectedId = roots[0] ?? null;
711
+ return {
712
+ surface: "tree",
713
+ searching: false,
714
+ selectedId,
715
+ focusId: null,
716
+ detailId: null,
717
+ expanded,
718
+ showInternals: false,
719
+ provFilter: null,
720
+ query: "",
721
+ helpOpen: false,
722
+ refreshing: false,
723
+ version: 0,
724
+ scrollOffset: 0,
725
+ };
726
+ }
727
+
728
+ /** The roots of a graph (nodes with no incoming contains/anchored-at edge). */
729
+ export function graphRoots(model: GraphModel): string[] {
730
+ const incoming = new Set<string>();
731
+ for (const e of model.edges) {
732
+ if (e.kind === "contains" || e.kind === "anchored-at") incoming.add(e.target);
733
+ }
734
+ return model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
735
+ }
736
+
737
+ export type Action =
738
+ | { type: "up" }
739
+ | { type: "down" }
740
+ | { type: "pageUp" }
741
+ | { type: "pageDown" }
742
+ | { type: "home" }
743
+ | { type: "end" }
744
+ | { type: "left" }
745
+ | { type: "right" }
746
+ | { type: "enter" }
747
+ | { type: "searchStart" }
748
+ | { type: "searchChar"; ch: string }
749
+ | { type: "searchBackspace" }
750
+ | { type: "searchCommit" }
751
+ | { type: "cycleProvenance" }
752
+ | { type: "toggleInternals" }
753
+ | { type: "focus" }
754
+ | { type: "focusExit" }
755
+ | { type: "surfaceTree" }
756
+ | { type: "surfaceHealth" }
757
+ | { type: "refresh" }
758
+ | { type: "refreshDone" }
759
+ | { type: "toggleHelp" }
760
+ | { type: "quit" }
761
+ | { type: "esc" };
762
+
763
+ /** Context reduce needs for movement: the selectable rows of the current
764
+ * surface (in display order) and the viewport window size. */
765
+ export interface ReduceCtx {
766
+ rows: readonly SelectableRow[];
767
+ window: number;
768
+ }
769
+
770
+ function bump(s: ExplorerState): ExplorerState {
771
+ return { ...s, version: s.version + 1 };
772
+ }
773
+
774
+ function clampIndex(idx: number, len: number): number {
775
+ if (len === 0) return -1;
776
+ if (idx >= len) return len - 1;
777
+ return idx;
778
+ }
779
+
780
+ function scrollForSelection(idx: number, window: number, prev: number): number {
781
+ if (window <= 0) return 0;
782
+ // keep selection visible: offset <= idx < offset + window
783
+ if (idx < prev) return idx;
784
+ if (idx >= prev + window) return idx - window + 1;
785
+ return prev;
786
+ }
787
+
788
+ function provenanceCycleNext(current: NoteSource | null): NoteSource | null {
789
+ const i = PROVENANCE_CYCLE.indexOf(current);
790
+ const next = PROVENANCE_CYCLE[(i + 1) % PROVENANCE_CYCLE.length];
791
+ return next ?? null;
792
+ }
793
+
794
+ /**
795
+ * Apply an action to the explorer state. Pure: returns a new state.
796
+ *
797
+ * Movement uses `ctx.rows` (the current surface's selectable rows) and
798
+ * `ctx.window` (viewport height) to clamp the cursor and adjust scroll.
799
+ * The component builds `ctx.rows` from the surface's model function.
800
+ */
801
+ export function reduce(state: ExplorerState, action: Action, ctx: ReduceCtx = { rows: [], window: 24 }): ExplorerState {
802
+ const rows = ctx.rows;
803
+ const len = rows.length;
804
+ const currentIdx = state.selectedId ? rows.findIndex((r) => r.id === state.selectedId || r.target === state.selectedId) : -1;
805
+
806
+ switch (action.type) {
807
+ case "up":
808
+ case "down":
809
+ case "pageUp":
810
+ case "pageDown":
811
+ case "home":
812
+ case "end": {
813
+ if (len === 0) return state;
814
+ let nextIdx: number;
815
+ if (action.type === "up") nextIdx = currentIdx <= 0 ? 0 : currentIdx - 1;
816
+ else if (action.type === "down") nextIdx = currentIdx < 0 ? 0 : Math.min(currentIdx + 1, len - 1);
817
+ else if (action.type === "pageUp") nextIdx = currentIdx < 0 ? 0 : Math.max(0, currentIdx - ctx.window);
818
+ else if (action.type === "pageDown") nextIdx = currentIdx < 0 ? 0 : Math.min(len - 1, currentIdx + ctx.window);
819
+ else if (action.type === "home") nextIdx = 0;
820
+ else nextIdx = len - 1; // end
821
+ nextIdx = clampIndex(nextIdx, len);
822
+ const row = rows[nextIdx];
823
+ if (!row) return state;
824
+ const selectedId = row.target ?? row.id;
825
+ const scrollOffset = scrollForSelection(nextIdx, ctx.window, state.scrollOffset);
826
+ return bump({ ...state, selectedId, scrollOffset });
827
+ }
828
+
829
+ case "left":
830
+ case "right": {
831
+ if (state.surface !== "tree") return state;
832
+ if (!state.selectedId) return state;
833
+ // operate on the tree: find the row for selectedId
834
+ const treeRowIdx = rows.findIndex((r) => r.id === state.selectedId);
835
+ const treeRow = treeRowIdx >= 0 ? (rows[treeRowIdx] as TreeRow | undefined) : undefined;
836
+ const isExpanded = state.expanded.has(state.selectedId);
837
+ if (action.type === "right") {
838
+ if (treeRow && treeRow.hasKids && !isExpanded) {
839
+ const expanded = new Set(state.expanded).add(state.selectedId);
840
+ return bump({ ...state, expanded });
841
+ }
842
+ if (treeRow && isExpanded) {
843
+ // move to first child
844
+ const child = rows[treeRowIdx + 1];
845
+ if (child) return bump({ ...state, selectedId: child.id, scrollOffset: scrollForSelection(treeRowIdx + 1, ctx.window, state.scrollOffset) });
846
+ }
847
+ return state;
848
+ }
849
+ // left: collapse, or jump to parent
850
+ if (treeRow && isExpanded) {
851
+ const expanded = new Set(state.expanded);
852
+ expanded.delete(state.selectedId);
853
+ return bump({ ...state, expanded });
854
+ }
855
+ // jump to parent: the nearest preceding row with smaller depth
856
+ for (let i = treeRowIdx - 1; i >= 0; i--) {
857
+ const r = rows[i] as TreeRow | undefined;
858
+ if (r && r.depth < (treeRow?.depth ?? 0)) {
859
+ return bump({ ...state, selectedId: r.id, scrollOffset: scrollForSelection(i, ctx.window, state.scrollOffset) });
860
+ }
861
+ }
862
+ return state;
863
+ }
864
+
865
+ case "enter": {
866
+ if (state.searching) {
867
+ // commit search: keep filter, exit search mode
868
+ return bump({ ...state, searching: false, scrollOffset: 0 });
869
+ }
870
+ if (!state.selectedId) return state;
871
+ const row = currentIdx >= 0 ? rows[currentIdx] : undefined;
872
+ if (state.surface === "tree") {
873
+ return bump({ ...state, surface: "detail", detailId: state.selectedId, scrollOffset: 0 });
874
+ }
875
+ if (state.surface === "detail") {
876
+ // jump to the selected link/backlink target
877
+ if (row && row.target) {
878
+ return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
879
+ }
880
+ return state;
881
+ }
882
+ if (state.surface === "focus") {
883
+ // re-center on the selected neighbor
884
+ if (row && row.target) {
885
+ return bump({ ...state, focusId: row.target, selectedId: row.target, scrollOffset: 0 });
886
+ }
887
+ return state;
888
+ }
889
+ if (state.surface === "health") {
890
+ if (row && row.target) {
891
+ return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
892
+ }
893
+ return state;
894
+ }
895
+ return state;
896
+ }
897
+
898
+ case "searchStart": {
899
+ if (state.searching) return state;
900
+ if (state.surface === "detail") return state;
901
+ return bump({ ...state, searching: true });
902
+ }
903
+
904
+ case "searchChar": {
905
+ if (!state.searching) return state;
906
+ const q = (state.query + action.ch).slice(0, MAX_FILTER_LEN);
907
+ return bump({ ...state, query: q, scrollOffset: 0 });
908
+ }
909
+
910
+ case "searchBackspace": {
911
+ if (!state.searching) return state;
912
+ return bump({ ...state, query: state.query.slice(0, -1), scrollOffset: 0 });
913
+ }
914
+
915
+ case "searchCommit": {
916
+ if (!state.searching) return state;
917
+ return bump({ ...state, searching: false, scrollOffset: 0 });
918
+ }
919
+
920
+ case "esc": {
921
+ // precedence: search > surface-exit > quit
922
+ if (state.searching) {
923
+ return bump({ ...state, searching: false, query: "", scrollOffset: 0 });
924
+ }
925
+ if (state.surface === "detail") {
926
+ return bump({ ...state, surface: "tree", scrollOffset: 0 });
927
+ }
928
+ if (state.surface === "focus") {
929
+ return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
930
+ }
931
+ if (state.surface === "health") {
932
+ return bump({ ...state, surface: "tree", scrollOffset: 0 });
933
+ }
934
+ // tree → quit (component resolves done(null))
935
+ return bump({ ...state });
936
+ }
937
+
938
+ case "quit": {
939
+ return bump({ ...state });
940
+ }
941
+
942
+ case "cycleProvenance": {
943
+ if (state.searching) return state;
944
+ return bump({ ...state, provFilter: provenanceCycleNext(state.provFilter), scrollOffset: 0 });
945
+ }
946
+
947
+ case "toggleInternals": {
948
+ if (state.searching) return state;
949
+ return bump({ ...state, showInternals: !state.showInternals, scrollOffset: 0 });
950
+ }
951
+
952
+ case "focus": {
953
+ if (state.searching) return state;
954
+ if (!state.selectedId) return state;
955
+ return bump({ ...state, surface: "focus", focusId: state.selectedId, scrollOffset: 0 });
956
+ }
957
+
958
+ case "focusExit": {
959
+ return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
960
+ }
961
+
962
+ case "surfaceTree": {
963
+ if (state.searching) return state;
964
+ return bump({ ...state, surface: "tree", scrollOffset: 0 });
965
+ }
966
+
967
+ case "surfaceHealth": {
968
+ if (state.searching) return state;
969
+ return bump({ ...state, surface: "health", scrollOffset: 0 });
970
+ }
971
+
972
+ case "refresh": {
973
+ return bump({ ...state, refreshing: true });
974
+ }
975
+
976
+ case "refreshDone": {
977
+ return bump({ ...state, refreshing: false });
978
+ }
979
+
980
+ case "toggleHelp": {
981
+ if (state.searching) return state;
982
+ return bump({ ...state, helpOpen: !state.helpOpen });
983
+ }
984
+ }
985
+ // Exhaustive over Action; unreachable for valid inputs.
986
+ return state;
987
+ }
988
+
989
+ /**
990
+ * Merge explorer state across a refresh (design §6): keep the expanded set,
991
+ * filter, surface, and selected node id. A selected/expanded id that no
992
+ * longer exists drops out; selection falls back to the first root.
993
+ */
994
+ export function mergeAfterRefresh(prev: ExplorerState, nextRoots: readonly string[]): ExplorerState {
995
+ let selectedId = prev.selectedId;
996
+ if (selectedId !== null && !nextRoots.includes(selectedId)) {
997
+ // selectedId may be a non-root; the component re-resolves it against the
998
+ // new tree rows. Roots fallback only when it is null.
999
+ }
1000
+ if (selectedId === null) selectedId = nextRoots[0] ?? null;
1001
+ return {
1002
+ ...prev,
1003
+ refreshing: false,
1004
+ selectedId,
1005
+ version: prev.version + 1,
1006
+ scrollOffset: 0,
1007
+ };
1008
+ }
1009
+
1010
+ // Re-export theme helpers some tests reach for.
1011
+ export { provenanceStyle, type ProvenanceStyle };