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,597 @@
1
+ /**
2
+ * WeaveExplorer — the weave-view TUI component (weave-view-tui-design §4, §10.2).
3
+ *
4
+ * A thin input/render shell: it holds the GraphModel + ExplorerState, builds
5
+ * per-surface row models from the pure `model.ts`, decodes keys into actions,
6
+ * applies `reduce`, and renders a windowed header + body + footer. All
7
+ * branching logic lives in `model.ts`; this file only maps keys → actions and
8
+ * strings → styled lines. Bodies (note markdown, .okf file text) load lazily
9
+ * through injected async loaders and are cached per node id.
10
+ *
11
+ * Dependencies are injected (`theme`, `tui`, loaders, `rebuild`, `openNote`,
12
+ * `done`) so the component is fully testable without a real terminal.
13
+ */
14
+
15
+ import { matchesKey, parseKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
16
+
17
+ import type { GraphModel, GraphNode, NodeKind } from "../../../core/graph/model";
18
+ import type { NoteSource } from "../../../core/types";
19
+ import type { ViewNote } from "../../../core/graph/current";
20
+ import {
21
+ detailModel,
22
+ focusModel,
23
+ graphRoots,
24
+ healthModel,
25
+ initialState,
26
+ reduce,
27
+ sanitizeTerminalText,
28
+ treeEmptyHint,
29
+ treeRows,
30
+ type Action,
31
+ type ExplorerState,
32
+ type ReduceCtx,
33
+ type SelectableRow,
34
+ type TreeRow,
35
+ } from "./model";
36
+ import { chevron, kindStyle, provenanceStyle, SELECTION_MARKER, type ThemeSlot } from "./theme";
37
+
38
+ /** Minimal theme surface the component uses (the real pi Theme satisfies it). */
39
+ export interface WeaveTheme {
40
+ fg(slot: ThemeSlot, text: string): string;
41
+ bg(slot: "selectedBg", text: string): string;
42
+ bold(text: string): string;
43
+ }
44
+
45
+ /** Minimal TUI surface the component uses (the real pi TUI satisfies it). */
46
+ export interface WeaveTui {
47
+ requestRender(force?: boolean): void;
48
+ terminal: { rows: number; columns: number };
49
+ }
50
+
51
+ /** Injected body loaders (bound to vault root / cwd by run.ts). */
52
+ export interface WeaveLoaders {
53
+ loadNote: (slug: string) => Promise<ViewNote | null>;
54
+ loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
55
+ /** Open a note in $EDITOR (the `o` key); returns false when the note is gone/unsafe. */
56
+ openNote: (slug: string) => Promise<boolean>;
57
+ /** Rebuild the graph from disk (the `r` key). */
58
+ rebuild: () => Promise<GraphModel>;
59
+ }
60
+
61
+ export interface WeaveExplorerOptions {
62
+ model: GraphModel;
63
+ theme: WeaveTheme;
64
+ tui: WeaveTui;
65
+ loaders: WeaveLoaders;
66
+ done: (result: null) => void;
67
+ /** Terminal rows for windowing; falls back to 24 (tests / unavailable). */
68
+ rows?: number;
69
+ /** Reference clock for relative-time meta (epoch ms); defaults to Date.now(). */
70
+ now?: () => number;
71
+ }
72
+
73
+ interface RenderedLine {
74
+ text: string;
75
+ /** Node id this line selects (for highlight + scroll-into-view). */
76
+ selectId?: string;
77
+ }
78
+
79
+ interface SurfaceRender {
80
+ lines: RenderedLine[];
81
+ rows: SelectableRow[];
82
+ }
83
+
84
+ const HEADER_LINES = 2;
85
+ const FOOTER_LINES = 1;
86
+ const MIN_WINDOW = 5;
87
+
88
+ /**
89
+ * Decode a raw terminal key sequence into an explorer action, given the
90
+ * current state (search mode changes how printable keys are interpreted).
91
+ * Exported for unit testing the decode path with real byte sequences.
92
+ */
93
+ export function decodeAction(data: string, state: ExplorerState): Action | null {
94
+ // Keys that work in every mode (arrows, enter, esc, paging).
95
+ if (matchesKey(data, "up")) return { type: "up" };
96
+ if (matchesKey(data, "down")) return { type: "down" };
97
+ if (matchesKey(data, "left")) return { type: "left" };
98
+ if (matchesKey(data, "right")) return { type: "right" };
99
+ if (matchesKey(data, "enter")) return { type: "enter" };
100
+ if (matchesKey(data, "escape")) return { type: "esc" };
101
+ if (matchesKey(data, "pageUp")) return { type: "pageUp" };
102
+ if (matchesKey(data, "pageDown")) return { type: "pageDown" };
103
+ if (matchesKey(data, "home")) return { type: "home" };
104
+ if (matchesKey(data, "end")) return { type: "end" };
105
+
106
+ // Search sub-mode: printable characters edit the filter; backspace deletes.
107
+ if (state.searching) {
108
+ if (matchesKey(data, "backspace")) return { type: "searchBackspace" };
109
+ const ch = parseKey(data);
110
+ if (ch === undefined) return null;
111
+ if (ch === "space") return { type: "searchChar", ch: " " };
112
+ if (ch.length === 1) return { type: "searchChar", ch };
113
+ return null;
114
+ }
115
+
116
+ // Vim-style hjkl duplicates (deliberate for the drill-down flow) + letters.
117
+ if (data === "k") return { type: "up" };
118
+ if (data === "j") return { type: "down" };
119
+ if (data === "h") return { type: "left" };
120
+ if (data === "l") return { type: "right" };
121
+ if (data === "/") return { type: "searchStart" };
122
+ if (data === "p") return { type: "cycleProvenance" };
123
+ if (data === "i") return { type: "toggleInternals" };
124
+ if (data === "f") return { type: "focus" };
125
+ if (data === "g") return { type: "focusExit" };
126
+ if (data === "1") return { type: "surfaceTree" };
127
+ if (data === "2") return { type: "surfaceHealth" };
128
+ if (data === "r") return { type: "refresh" };
129
+ if (data === "?") return { type: "toggleHelp" };
130
+ if (data === "q") return { type: "quit" };
131
+ return null;
132
+ }
133
+
134
+ export class WeaveExplorer implements Component {
135
+ private model: GraphModel;
136
+ private readonly theme: WeaveTheme;
137
+ private readonly tui: WeaveTui;
138
+ private readonly loaders: WeaveLoaders;
139
+ private readonly done: (result: null) => void;
140
+ private readonly rows: number;
141
+ private readonly nowFn: () => number;
142
+
143
+ state: ExplorerState;
144
+ /** Cached note/okf bodies keyed by node id; null = not yet loaded. */
145
+ private bodyCache = new Map<string, string | null>();
146
+ /** In-flight body loads, keyed by node id. */
147
+ private bodyLoading = new Set<string>();
148
+ /** Render cache keyed by `${width}:${version}`. */
149
+ private renderCache = new Map<string, string[]>();
150
+ wantsKeyRelease = false;
151
+
152
+ constructor(opts: WeaveExplorerOptions) {
153
+ this.model = opts.model;
154
+ this.theme = opts.theme;
155
+ this.tui = opts.tui;
156
+ this.loaders = opts.loaders;
157
+ this.done = opts.done;
158
+ this.rows = opts.rows ?? 24;
159
+ this.nowFn = opts.now ?? Date.now;
160
+ this.state = initialState(graphRoots(this.model));
161
+ }
162
+
163
+ /** Replace the graph (used by the `r` refresh). Preserves selection by id. */
164
+ setModel(model: GraphModel): void {
165
+ this.model = model;
166
+ this.bodyCache.clear();
167
+ this.bodyLoading.clear();
168
+ this.state = reduce(this.state, { type: "refreshDone" });
169
+ // Re-resolve selection: keep id if still present, else first root.
170
+ if (this.state.selectedId && !this.nodeExists(this.state.selectedId)) {
171
+ this.state = { ...this.state, selectedId: graphRoots(this.model)[0] ?? null, scrollOffset: 0 };
172
+ }
173
+ this.invalidate();
174
+ }
175
+
176
+ invalidate(): void {
177
+ this.renderCache.clear();
178
+ }
179
+
180
+ handleInput(data: string): void {
181
+ // Side-effect keys resolved before reduce (they don't change model state).
182
+ if (data === "o" && !this.state.searching) {
183
+ this.openSelectedInEditor();
184
+ return;
185
+ }
186
+ const action = decodeAction(data, this.state);
187
+ if (action === null) return;
188
+
189
+ // q / tree-esc quit the explorer.
190
+ if (action.type === "quit" || (action.type === "esc" && this.state.surface === "tree" && !this.state.searching)) {
191
+ this.done(null);
192
+ return;
193
+ }
194
+
195
+ // r refresh: rebuild from disk; old view stays up, banner flips to refreshing.
196
+ if (action.type === "refresh") {
197
+ if (this.state.refreshing) return;
198
+ this.state = reduce(this.state, { type: "refresh" });
199
+ this.invalidate();
200
+ this.tui.requestRender();
201
+ void this.loaders
202
+ .rebuild()
203
+ .then((model) => {
204
+ this.setModel(model);
205
+ })
206
+ .catch(() => {
207
+ this.state = reduce(this.state, { type: "refreshDone" });
208
+ this.invalidate();
209
+ this.tui.requestRender();
210
+ });
211
+ return;
212
+ }
213
+
214
+ const rows = this.currentRows();
215
+ const ctx: ReduceCtx = { rows, window: this.windowSize() };
216
+ const prev = this.state;
217
+ let next = reduce(prev, action, ctx);
218
+
219
+ // When entering detail for a note/okf node, kick off the body load.
220
+ if (next.surface === "detail" && next.detailId !== prev.detailId && next.detailId !== null) {
221
+ this.maybeLoadBody(next.detailId);
222
+ }
223
+ // When focus re-centers on a note, no body needed.
224
+
225
+ this.state = next;
226
+ this.invalidate();
227
+ this.tui.requestRender();
228
+ }
229
+
230
+ render(width: number): string[] {
231
+ const key = `${width}:${this.state.version}`;
232
+ const cached = this.renderCache.get(key);
233
+ if (cached) return cached;
234
+
235
+ const lines: string[] = [];
236
+ lines.push(...this.renderHeader(width));
237
+ lines.push(...this.renderBody(width));
238
+ if (this.state.searching) lines.push(...this.renderSearchLine(width));
239
+ lines.push(...this.renderFooter(width));
240
+
241
+ // Pad/truncate every line to the viewport width and clamp to terminal rows.
242
+ const out: string[] = [];
243
+ const maxRows = this.rows;
244
+ for (let i = 0; i < lines.length && i < maxRows; i++) {
245
+ const line = lines[i] ?? "";
246
+ const w = visibleWidth(line);
247
+ if (w > width) out.push(truncateToWidth(line, width));
248
+ else out.push(line);
249
+ }
250
+
251
+ this.renderCache.set(key, out);
252
+ return out;
253
+ }
254
+
255
+ // ----- rendering -----
256
+
257
+ private renderHeader(width: number): string[] {
258
+ const t = this.theme;
259
+ const line1 = t.bold(`🧵 weave view — data as of ${this.model.generatedAt || "now"}`) + ` ${this.surfaceName()}`;
260
+ const counts = this.countNotes();
261
+ const repo = this.model.nodes.find((n) => n.kind === "repository");
262
+ const repoState = this.model.staleness?.state ?? (repo ? "fresh" : "missing");
263
+ const repoPart = repo ? ` · repo ${repo.label}:${repoState}` : "";
264
+ const line2 = `notes ${counts.total} (● ${counts.human} / ◐ ${counts.agent} / ○ ${counts.generated})${repoPart}`;
265
+ const lines = [line1, line2];
266
+ // conditional filter/focus banner (line 3)
267
+ const banner = this.bannerText();
268
+ if (banner) lines.push(t.fg("warning", banner));
269
+ return lines.map((l) => truncateToWidth(l, width));
270
+ }
271
+
272
+ private renderFooter(width: number): string[] {
273
+ const t = this.theme;
274
+ if (this.state.helpOpen) {
275
+ const help = [
276
+ "↑↓/jk move · ←→/hl collapse/expand · enter open · / filter · p prov · i internals",
277
+ "f focus · g/esc exit focus · 1 tree · 2 health · r refresh · o editor · ? help · q quit",
278
+ ];
279
+ return help.map((l) => t.fg("dim", truncateToWidth(l, width)));
280
+ }
281
+ const hint = this.state.searching
282
+ ? "search: type to filter · enter keep · esc clear"
283
+ : "↑↓ move · enter open · / filter · f focus · 2 health · r refresh · o editor · ? help · q quit";
284
+ return [t.fg("dim", truncateToWidth(hint, width))];
285
+ }
286
+
287
+ private renderSearchLine(width: number): string[] {
288
+ const t = this.theme;
289
+ const prompt = t.fg("accent", "/");
290
+ const q = sanitizeTerminalText(this.state.query);
291
+ return [truncateToWidth(`${prompt}${q}`, width)];
292
+ }
293
+
294
+ private renderBody(width: number): string[] {
295
+ const surface = this.renderSurface(width);
296
+ const lines = surface.lines;
297
+ // find selected line index
298
+ const selId = this.state.selectedId;
299
+ let selLine = -1;
300
+ if (selId) {
301
+ for (let i = 0; i < lines.length; i++) {
302
+ if (lines[i]?.selectId === selId) {
303
+ selLine = i;
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ // window
309
+ const window = Math.max(MIN_WINDOW, this.windowSize());
310
+ let offset = this.state.scrollOffset;
311
+ if (selLine >= 0) {
312
+ if (selLine < offset) offset = selLine;
313
+ else if (selLine >= offset + window) offset = selLine - window + 1;
314
+ }
315
+ offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - window)));
316
+ const out: string[] = [];
317
+ const end = Math.min(lines.length, offset + window);
318
+ for (let i = offset; i < end; i++) {
319
+ const ln = lines[i];
320
+ if (!ln) continue;
321
+ const isSel = i === selLine;
322
+ let text = ln.text;
323
+ if (isSel) {
324
+ text = `${SELECTION_MARKER} ${this.theme.bg("selectedBg", text)}`;
325
+ } else {
326
+ text = ` ${text}`;
327
+ }
328
+ out.push(truncateToWidth(text, width));
329
+ }
330
+ // scroll indicators
331
+ if (offset > 0) out.unshift(this.theme.fg("dim", "▲ more"));
332
+ if (end < lines.length) out.push(this.theme.fg("dim", "▼ more"));
333
+ return out;
334
+ }
335
+
336
+ private renderSurface(width: number): SurfaceRender {
337
+ switch (this.state.surface) {
338
+ case "tree":
339
+ return this.renderTree(width);
340
+ case "detail":
341
+ return this.renderDetail(width);
342
+ case "focus":
343
+ return this.renderFocus(width);
344
+ case "health":
345
+ return this.renderHealth(width);
346
+ }
347
+ }
348
+
349
+ private renderTree(width: number): SurfaceRender {
350
+ const t = this.theme;
351
+ const rows = treeRows(this.model, {
352
+ expanded: this.state.expanded,
353
+ showInternals: this.state.showInternals,
354
+ provFilter: this.state.provFilter,
355
+ query: this.state.query,
356
+ now: this.nowFn(),
357
+ });
358
+ const hint = treeEmptyHint(this.model);
359
+ if (rows.length === 0 && hint) {
360
+ return { lines: [{ text: t.fg("dim", hint) }], rows: [] };
361
+ }
362
+ const lines: RenderedLine[] = rows.map((r) => {
363
+ const chev = chevron(r.expanded, r.hasKids);
364
+ const marker = this.rowMarker(r.kind, r.provenance);
365
+ const label = sanitizeTerminalText(r.label);
366
+ const meta = r.meta ? t.fg("dim", ` ${sanitizeTerminalText(r.meta)}`) : "";
367
+ const indent = " ".repeat(r.depth);
368
+ const body = `${indent}${chev} ${marker}${label}`;
369
+ const text = meta ? `${body}${meta}` : body;
370
+ return { text, selectId: r.id };
371
+ });
372
+ return { lines, rows };
373
+ }
374
+
375
+ private renderDetail(width: number): SurfaceRender {
376
+ const t = this.theme;
377
+ const id = this.state.detailId;
378
+ if (!id) return { lines: [{ text: t.fg("dim", "(no selection)") }], rows: [] };
379
+ const d = detailModel(this.model, id);
380
+ if (!d) return { lines: [{ text: t.fg("dim", "(node not found)") }], rows: [] };
381
+
382
+ const lines: RenderedLine[] = [];
383
+ // title
384
+ const marker = this.rowMarker(d.kind, d.provenance);
385
+ const provBadge = d.provenance ? ` ${provenanceStyle(d.provenance).glyph} ${provenanceStyle(d.provenance).word}` : "";
386
+ lines.push({ text: t.bold(`${marker}${sanitizeTerminalText(d.label)}`) + t.fg("muted", ` (${d.kind}${provBadge})`) });
387
+
388
+ // meta rows (selectable for scroll)
389
+ for (const m of d.meta) {
390
+ lines.push({
391
+ text: `${t.fg("muted", sanitizeTerminalText(m.label))}: ${sanitizeTerminalText(m.value)}`,
392
+ selectId: m.id,
393
+ });
394
+ }
395
+
396
+ // body
397
+ const bodyLines = this.bodyLinesFor(id, width);
398
+ for (const bl of bodyLines) lines.push({ text: bl });
399
+
400
+ // links
401
+ if (d.links.length > 0) {
402
+ lines.push({ text: t.fg("accent", "Links") });
403
+ for (const lk of d.links) {
404
+ lines.push({ text: ` ${this.rowMarker(lk.kind, lk.provenance)}${sanitizeTerminalText(lk.label)}`, selectId: lk.id, });
405
+ }
406
+ }
407
+ if (d.backlinks.length > 0) {
408
+ lines.push({ text: t.fg("accent", "Backlinks") });
409
+ for (const lk of d.backlinks) {
410
+ lines.push({ text: ` ${this.rowMarker(lk.kind, lk.provenance)}${sanitizeTerminalText(lk.label)}`, selectId: lk.id });
411
+ }
412
+ }
413
+
414
+ const rows: SelectableRow[] = [...d.meta.map((m) => ({ id: m.id })), ...d.links, ...d.backlinks];
415
+ return { lines, rows };
416
+ }
417
+
418
+ private renderFocus(width: number): SurfaceRender {
419
+ const t = this.theme;
420
+ const id = this.state.focusId;
421
+ if (!id) return { lines: [{ text: t.fg("dim", "(no focus node)") }], rows: [] };
422
+ const f = focusModel(this.model, id);
423
+ const lines: RenderedLine[] = [];
424
+ const cm = this.rowMarker(f.center.kind, f.center.provenance);
425
+ lines.push({ text: t.bold(`${cm}${sanitizeTerminalText(f.center.label)}`) + t.fg("dim", " (focus — g/esc to exit)"), selectId: f.center.id });
426
+ for (const g of f.groups) {
427
+ lines.push({ text: t.fg("accent", g.heading) });
428
+ for (const r of g.rows) {
429
+ lines.push({ text: ` ${this.rowMarker(r.kind, r.provenance)}${sanitizeTerminalText(r.label)}`, selectId: r.id });
430
+ }
431
+ }
432
+ const rows: SelectableRow[] = [{ id: f.center.id, target: f.center.id }, ...f.groups.flatMap((g) => g.rows)];
433
+ return { lines, rows };
434
+ }
435
+
436
+ private renderHealth(width: number): SurfaceRender {
437
+ const t = this.theme;
438
+ const h = healthModel(this.model);
439
+ const lines: RenderedLine[] = [];
440
+ const rows: SelectableRow[] = [];
441
+ for (const s of h.sections) {
442
+ lines.push({ text: t.bold(s.heading) });
443
+ for (const r of s.rows) {
444
+ lines.push(r.target ? { text: sanitizeTerminalText(r.text), selectId: r.id } : { text: sanitizeTerminalText(r.text) });
445
+ if (r.target) rows.push({ id: r.id, target: r.target });
446
+ }
447
+ }
448
+ return { lines, rows };
449
+ }
450
+
451
+ // ----- helpers -----
452
+
453
+ private rowMarker(kind: NodeKind, prov: NoteSource | null): string {
454
+ const ks = kindStyle(kind);
455
+ if (kind === "note") {
456
+ const ps = provenanceStyle(prov);
457
+ return ps.glyph ? `${ps.glyph} ` : "";
458
+ }
459
+ return ks.glyph ? `${t_fg(this.theme, ks.slot, `${ks.glyph} `)}` : "";
460
+ }
461
+
462
+ private windowSize(): number {
463
+ const banner = this.bannerText() ? 1 : 0;
464
+ const search = this.state.searching ? 1 : 0;
465
+ return Math.max(MIN_WINDOW, this.rows - HEADER_LINES - banner - search - FOOTER_LINES);
466
+ }
467
+
468
+ private bannerText(): string | null {
469
+ const parts: string[] = [];
470
+ if (this.state.provFilter) parts.push(`prov: ${provenanceStyle(this.state.provFilter).glyph} ${provenanceStyle(this.state.provFilter).word}`);
471
+ if (this.state.query) parts.push(`filter: ${this.state.query}`);
472
+ if (this.state.focusId && this.state.surface !== "focus") parts.push(`focus: ${this.state.focusId}`);
473
+ if (this.state.refreshing) parts.push("refreshing…");
474
+ return parts.length > 0 ? parts.join(" · ") : null;
475
+ }
476
+
477
+ private surfaceName(): string {
478
+ switch (this.state.surface) {
479
+ case "tree": return "Explore";
480
+ case "detail": return "Detail";
481
+ case "focus": return "Focus";
482
+ case "health": return "Health";
483
+ }
484
+ }
485
+
486
+ private countNotes(): { total: number; human: number; agent: number; generated: number } {
487
+ let human = 0, agent = 0, generated = 0, total = 0;
488
+ for (const n of this.model.nodes) {
489
+ if (n.kind !== "note") continue;
490
+ total++;
491
+ if (n.provenance === "human") human++;
492
+ else if (n.provenance === "agent") agent++;
493
+ else if (n.provenance === "generated") generated++;
494
+ }
495
+ return { total, human, agent, generated };
496
+ }
497
+
498
+ private currentRows(): SelectableRow[] {
499
+ return this.renderSurface(80).rows;
500
+ }
501
+
502
+ private nodeExists(id: string): boolean {
503
+ return this.model.nodes.some((n) => n.id === id);
504
+ }
505
+
506
+ private maybeLoadBody(id: string): void {
507
+ if (this.bodyCache.has(id) || this.bodyLoading.has(id)) return;
508
+ const node = this.model.nodes.find((n) => n.id === id);
509
+ if (!node) return;
510
+ if (node.kind === "note") {
511
+ const slug = node.detail.slug;
512
+ if (!slug) return;
513
+ this.bodyLoading.add(id);
514
+ void this.loaders.loadNote(slug).then((note) => {
515
+ this.bodyCache.set(id, note?.body ?? null);
516
+ this.bodyLoading.delete(id);
517
+ this.state = { ...this.state, version: this.state.version + 1 };
518
+ this.invalidate();
519
+ this.tui.requestRender();
520
+ });
521
+ } else if (node.kind === "file") {
522
+ const rel = node.detail.path;
523
+ if (!rel) return;
524
+ this.bodyLoading.add(id);
525
+ void this.loaders.loadOkf(rel).then((file) => {
526
+ this.bodyCache.set(id, file?.body ?? null);
527
+ this.bodyLoading.delete(id);
528
+ this.state = { ...this.state, version: this.state.version + 1 };
529
+ this.invalidate();
530
+ this.tui.requestRender();
531
+ });
532
+ }
533
+ }
534
+
535
+ private bodyLinesFor(id: string, width: number): string[] {
536
+ // Only notes and .okf file nodes carry a body; every other kind (vault,
537
+ // repository, module, package, entryPoint, gitState, external) has meta +
538
+ // links only. Rendering a placeholder for them would queue a load that
539
+ // never completes (maybeLoadBody skips non-note/file kinds).
540
+ const node = this.model.nodes.find((n) => n.id === id);
541
+ if (!node || (node.kind !== "note" && node.kind !== "file")) return [];
542
+ if (this.bodyLoading.has(id)) return [this.theme.fg("dim", "(loading…)")];
543
+ const body = this.bodyCache.get(id);
544
+ if (body === undefined) {
545
+ // not yet requested — trigger a load (render is sync, so show placeholder)
546
+ queueMicrotask(() => this.maybeLoadBody(id));
547
+ return [this.theme.fg("dim", "(loading…)")];
548
+ }
549
+ if (body === null) return [];
550
+ // Render body lines, wrapped by visible width (no Markdown component in v1
551
+ // to keep the component single-file and testable; bodies wrap as plain text).
552
+ const wrapped = wrapPlain(body, Math.max(10, width - 2));
553
+ return wrapped.map((l) => ` ${this.theme.fg("text", sanitizeTerminalText(l))}`);
554
+ }
555
+
556
+ private openSelectedInEditor(): void {
557
+ const id = this.state.selectedId ?? this.state.detailId;
558
+ if (!id) return;
559
+ const node = this.model.nodes.find((n) => n.id === id);
560
+ if (!node || node.kind !== "note") return;
561
+ const slug = node.detail.slug;
562
+ if (!slug) return;
563
+ void this.loaders.openNote(slug);
564
+ }
565
+ }
566
+
567
+ /** Wrap plain text to a visible width, preserving newlines. */
568
+ function wrapPlain(text: string, width: number): string[] {
569
+ const out: string[] = [];
570
+ for (const para of text.split("\n")) {
571
+ if (para.length === 0) {
572
+ out.push("");
573
+ continue;
574
+ }
575
+ let col = 0;
576
+ let line = "";
577
+ for (const word of para.split(/(\s+)/)) {
578
+ if (col + visibleWidth(word) > width && line.length > 0) {
579
+ out.push(line);
580
+ line = word.trimStart();
581
+ col = visibleWidth(line);
582
+ } else {
583
+ line += word;
584
+ col += visibleWidth(word);
585
+ }
586
+ }
587
+ out.push(line);
588
+ }
589
+ return out;
590
+ }
591
+
592
+ function t_fg(theme: WeaveTheme, slot: ThemeSlot, text: string): string {
593
+ return theme.fg(slot, text);
594
+ }
595
+
596
+ // Re-export types the wiring/tests reach for.
597
+ export type { Component, GraphNode };