pi-weave 0.1.6 → 0.1.8

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.
Files changed (96) hide show
  1. package/README.md +114 -37
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +102 -19
  24. package/src/pi/viewer/tui/bodyStore.ts +98 -0
  25. package/src/pi/viewer/tui/branding.ts +154 -0
  26. package/src/pi/viewer/tui/explorer.ts +20 -31
  27. package/src/pi/viewer/tui/model.ts +46 -667
  28. package/src/pi/viewer/tui/openNote.ts +11 -0
  29. package/src/pi/viewer/tui/run.ts +13 -4
  30. package/src/pi/viewer/tui/surface/base.ts +182 -0
  31. package/src/pi/viewer/tui/surface/detail.ts +315 -0
  32. package/src/pi/viewer/tui/surface/explore.ts +206 -0
  33. package/src/pi/viewer/tui/surface/focus.ts +139 -0
  34. package/src/pi/viewer/tui/surface/health.ts +102 -0
  35. package/src/pi/viewer/tui/workspace.ts +381 -0
  36. package/src/pi/viewer/tui/workspaceRoot.ts +530 -0
  37. package/src/pi/viewer/web/run.ts +331 -0
  38. package/src/web/client/api.dom.ts +40 -0
  39. package/src/web/client/api.ts +472 -0
  40. package/src/web/client/bootstrap.ts +58 -0
  41. package/src/web/client/context/context.model.ts +313 -0
  42. package/src/web/client/dist/app.js +751 -0
  43. package/src/web/client/graph/Graph.tsx +158 -0
  44. package/src/web/client/graph/column.model.ts +431 -0
  45. package/src/web/client/graph/graph.model.ts +538 -0
  46. package/src/web/client/graph/positions.ts +339 -0
  47. package/src/web/client/graph/project.ts +153 -0
  48. package/src/web/client/graph/renderer.dom.ts +52 -0
  49. package/src/web/client/graph/renderer.ts +279 -0
  50. package/src/web/client/graph/scheme.ts +44 -0
  51. package/src/web/client/live.model.ts +275 -0
  52. package/src/web/client/live.ts +151 -0
  53. package/src/web/client/main.tsx +27 -0
  54. package/src/web/client/note/Editor.tsx +102 -0
  55. package/src/web/client/note/Note.tsx +113 -0
  56. package/src/web/client/note/editor.controller.ts +151 -0
  57. package/src/web/client/note/editor.model.ts +636 -0
  58. package/src/web/client/note/note.model.ts +738 -0
  59. package/src/web/client/search/SearchPalette.tsx +105 -0
  60. package/src/web/client/search/search.model.ts +588 -0
  61. package/src/web/client/search/search.ts +107 -0
  62. package/src/web/client/shell/Columns.tsx +161 -0
  63. package/src/web/client/shell/ContextRail.tsx +87 -0
  64. package/src/web/client/shell/Divider.tsx +44 -0
  65. package/src/web/client/shell/FocusTrap.tsx +56 -0
  66. package/src/web/client/shell/Header.tsx +54 -0
  67. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  68. package/src/web/client/shell/Shell.tsx +193 -0
  69. package/src/web/client/shell/StatusBar.tsx +28 -0
  70. package/src/web/client/shell/cssvars.ts +70 -0
  71. package/src/web/client/shell/drag.model.ts +170 -0
  72. package/src/web/client/shell/focus.model.ts +100 -0
  73. package/src/web/client/shell/keys.model.ts +453 -0
  74. package/src/web/client/shell/keys.ts +59 -0
  75. package/src/web/client/shell/layout.model.ts +526 -0
  76. package/src/web/client/shell/shell.model.ts +333 -0
  77. package/src/web/client/shell/theme.ts +477 -0
  78. package/src/web/client/shell/viewport.ts +29 -0
  79. package/src/web/client/state.ts +78 -0
  80. package/src/web/client/tree/Tree.tsx +138 -0
  81. package/src/web/client/tree/tree.model.ts +674 -0
  82. package/src/web/client/workspace.ts +214 -0
  83. package/src/web/server/page.ts +256 -0
  84. package/src/web/server/routes.ts +975 -0
  85. package/src/web/server/security.ts +361 -0
  86. package/src/web/server/server.ts +275 -0
  87. package/src/web/server/sse.ts +321 -0
  88. package/src/web/server/watcher.ts +507 -0
  89. package/src/web/shared/graph.ts +206 -0
  90. package/src/web/shared/layout.ts +497 -0
  91. package/src/web/shared/metrics.ts +136 -0
  92. package/src/web/shared/view.ts +200 -0
  93. package/src/web/shared/wire.ts +358 -0
  94. package/src/pi/viewer/browser.ts +0 -29
  95. package/src/pi/viewer/page.ts +0 -1316
  96. package/src/pi/viewer/server.ts +0 -229
@@ -0,0 +1,206 @@
1
+ /**
2
+ * surface/explore.ts — the Explore surface as a Component (weave-view-tui-v2 §6).
3
+ *
4
+ * Wraps the v1 tree view-model (`treeRows` + `reduce`/`ExplorerState`) so each
5
+ * Explore pane owns its own selection, expansion, filter, provenance cycle,
6
+ * and search sub-mode — independent of other panes. Cross-pane navigation
7
+ * (enter → open detail, f → open focus) is emitted as a `SurfaceEvent` that
8
+ * the workspace root resolves (e.g. into the nearest Detail pane).
9
+ */
10
+
11
+ import { truncateToWidth } from "@earendil-works/pi-tui";
12
+ import { decodeAction } from "../explorer";
13
+ import {
14
+ formatTreeMeta,
15
+ graphRoots,
16
+ initialState,
17
+ reduce,
18
+ treeEmptyHint,
19
+ treeRows,
20
+ type Action,
21
+ type ExplorerState,
22
+ } from "../model";
23
+ import { chevron, kindStyle, provenanceStyle, SELECTION_MARKER, type ThemeSlot } from "../theme";
24
+ import type { GraphModel } from "../../../../core/graph/model";
25
+ import type { NoteSource } from "../../../../core/types";
26
+ import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit, type SurfaceRender } from "./base";
27
+
28
+ /** The Explore surface's own state (wraps the v1 ExplorerState for the tree). */
29
+ export interface ExploreSurfaceState {
30
+ selectedId: string | null;
31
+ expanded: Set<string>;
32
+ showInternals: boolean;
33
+ provFilter: NoteSource | null;
34
+ query: string;
35
+ searching: boolean;
36
+ scrollOffset: number;
37
+ }
38
+
39
+ export function initialExploreState(model: GraphModel): ExploreSurfaceState {
40
+ const s = initialState(graphRoots(model));
41
+ return {
42
+ selectedId: s.selectedId,
43
+ expanded: s.expanded,
44
+ showInternals: s.showInternals,
45
+ provFilter: s.provFilter,
46
+ query: s.query,
47
+ searching: s.searching,
48
+ scrollOffset: 0,
49
+ };
50
+ }
51
+
52
+ export class ExploreSurface implements Surface {
53
+ readonly kind = "explore" as const;
54
+ private readonly ctx;
55
+ private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
56
+ state: ExploreSurfaceState;
57
+ private focused = false;
58
+ /** Pane viewport height (rows); set by the workspace root. */
59
+ paneRows = 24;
60
+ private renderCache = new Map<string, string[]>();
61
+
62
+ constructor(init: SurfaceInit) {
63
+ this.ctx = init.context;
64
+ this.onEvent = init.onEvent;
65
+ this.state = initialExploreState(this.ctx.model);
66
+ }
67
+
68
+ title(): string {
69
+ return "Explore";
70
+ }
71
+
72
+ setFocused(focused: boolean): void {
73
+ this.focused = focused;
74
+ }
75
+
76
+ invalidate(): void {
77
+ this.renderCache.clear();
78
+ }
79
+
80
+ /** Navigate the tree with a key sequence (delegates to v1 decodeAction + reduce). */
81
+ handleInput(data: string): void {
82
+ // o opens the selected node in the external editor (parity with v1's
83
+ // WeaveExplorer and the Detail surface; the workspace root resolves the
84
+ // event into loaders.openNote/openFile).
85
+ if (data === "o" && !this.state.searching) {
86
+ if (this.state.selectedId) this.onEvent?.({ type: "openEditor", id: this.state.selectedId });
87
+ return;
88
+ }
89
+ const action = decodeAction(data, toExplorerState(this.state));
90
+ if (!action) return;
91
+ this.applyAction(action);
92
+ }
93
+
94
+ private applyAction(action: Action): void {
95
+ // Cross-pane navigation: enter → open detail; f → open focus.
96
+ if (action.type === "enter" && !this.state.searching) {
97
+ if (this.state.selectedId) this.onEvent?.({ type: "openDetail", id: this.state.selectedId });
98
+ return;
99
+ }
100
+ if (action.type === "focus" && !this.state.searching) {
101
+ if (this.state.selectedId) this.onEvent?.({ type: "focusNode", id: this.state.selectedId });
102
+ return;
103
+ }
104
+ const rows = this.renderRows(80);
105
+ const prev = toExplorerState(this.state);
106
+ const next = reduce(prev, action, { rows, window: Math.max(5, this.paneRows) });
107
+ this.state = fromExplorerState(next);
108
+ }
109
+
110
+ private renderRows(width: number): { id: string; target?: string }[] {
111
+ return this.renderSurface(width).rows;
112
+ }
113
+
114
+ render(width: number): string[] {
115
+ const key = `${width}:${this.state.scrollOffset}:${this.state.selectedId}:${this.state.query}:${this.state.provFilter ?? ""}:${this.state.showInternals}:${this.state.searching}`;
116
+ const cached = this.renderCache.get(key);
117
+ if (cached) return cached;
118
+ const out = this.renderSurface(width).lines.map((l) => truncateToWidth(l, width));
119
+ this.renderCache.set(key, out);
120
+ return out;
121
+ }
122
+
123
+ private renderSurface(width: number): SurfaceRender {
124
+ const t = this.ctx.theme;
125
+ const rows = treeRows(this.ctx.model, {
126
+ expanded: this.state.expanded,
127
+ showInternals: this.state.showInternals,
128
+ provFilter: this.state.provFilter,
129
+ query: this.state.query,
130
+ });
131
+ const now = this.ctx.now();
132
+ const hint = treeEmptyHint(this.ctx.model);
133
+ if (rows.length === 0 && hint) {
134
+ return { lines: [t.fg("dim", hint)], rows: [] };
135
+ }
136
+ const lines: { text: string; id: string }[] = rows.map((r) => {
137
+ const chev = chevron(r.expanded, r.hasKids);
138
+ const marker = this.rowMarker(r.kind, r.provenance);
139
+ const label = sanitize(r.label);
140
+ const metaText = formatTreeMeta(r.meta, now);
141
+ const meta = metaText ? t.fg("dim", ` ${sanitize(metaText)}`) : "";
142
+ const indent = " ".repeat(r.depth);
143
+ const body = `${indent}${chev} ${marker}${label}`;
144
+ return { text: meta ? `${body}${meta}` : body, id: r.id };
145
+ });
146
+ const selId = this.state.selectedId;
147
+ const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
148
+ const marker = (text: string) => `${SELECTION_MARKER} ${this.ctx.theme.bg("selectedBg", text)}`;
149
+ const out: string[] = windowLines(
150
+ lines.map((l) => l.text),
151
+ selLine,
152
+ this.state.scrollOffset,
153
+ Math.max(5, this.paneRows),
154
+ marker,
155
+ );
156
+ if (this.state.searching) {
157
+ const prompt = t.fg("accent", "/");
158
+ out.push(`${prompt}${sanitize(this.state.query)}`);
159
+ }
160
+ return { lines: out, rows: rows.map((r) => ({ id: r.id })) };
161
+ }
162
+
163
+ private rowMarker(kind: string, prov: NoteSource | null): string {
164
+ const ks = kindStyle(kind as never);
165
+ if (kind === "note") {
166
+ const ps = provenanceStyle(prov);
167
+ return ps.glyph ? `${ps.glyph} ` : "";
168
+ }
169
+ return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
170
+ }
171
+ }
172
+
173
+ function sanitize(s: string): string {
174
+ // eslint-disable-next-line no-control-regex
175
+ return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
176
+ }
177
+
178
+ function toExplorerState(s: ExploreSurfaceState): ExplorerState {
179
+ return {
180
+ surface: "tree",
181
+ searching: s.searching,
182
+ selectedId: s.selectedId,
183
+ focusId: null,
184
+ detailId: null,
185
+ expanded: s.expanded,
186
+ showInternals: s.showInternals,
187
+ provFilter: s.provFilter,
188
+ query: s.query,
189
+ helpOpen: false,
190
+ refreshing: false,
191
+ version: 0,
192
+ scrollOffset: s.scrollOffset,
193
+ };
194
+ }
195
+
196
+ function fromExplorerState(s: ExplorerState): ExploreSurfaceState {
197
+ return {
198
+ selectedId: s.selectedId,
199
+ expanded: s.expanded,
200
+ showInternals: s.showInternals,
201
+ provFilter: s.provFilter,
202
+ query: s.query,
203
+ searching: s.searching,
204
+ scrollOffset: s.scrollOffset,
205
+ };
206
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * surface/focus.ts — the Focus surface as a Component (weave-view-tui-v2 §6).
3
+ *
4
+ * The 1-hop graph neighborhood (`focusModel`). Enter re-centers the focus on
5
+ * the selected neighbor, walking the graph. Each Focus pane owns its own
6
+ * center node + selection/scroll.
7
+ */
8
+
9
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { focusModel, sanitizeTerminalText } from "../model";
11
+ import { kindStyle, provenanceStyle, type ThemeSlot } from "../theme";
12
+ import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
13
+ import type { NoteSource } from "../../../../core/types";
14
+ import type { GraphModel } from "../../../../core/graph/model";
15
+
16
+ export interface FocusSurfaceState {
17
+ focusId: string | null;
18
+ selectedId: string | null;
19
+ scrollOffset: number;
20
+ }
21
+
22
+ export class FocusSurface implements Surface {
23
+ readonly kind = "focus" as const;
24
+ private readonly ctx;
25
+ private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
26
+ state: FocusSurfaceState;
27
+ paneRows = 24;
28
+
29
+ constructor(init: SurfaceInit) {
30
+ this.ctx = init.context;
31
+ this.onEvent = init.onEvent;
32
+ this.state = { focusId: null, selectedId: null, scrollOffset: 0 };
33
+ }
34
+
35
+ /** Bind the focus center node (called when opening/`f`). */
36
+ setFocus(nodeId: string): void {
37
+ this.state = { focusId: nodeId, selectedId: nodeId, scrollOffset: 0 };
38
+ }
39
+
40
+ setFocused(_focused: boolean): void {}
41
+
42
+ title(): string {
43
+ return "Focus";
44
+ }
45
+
46
+ invalidate(): void {}
47
+
48
+ rebind(model: GraphModel): void {
49
+ // After a refresh, drop a focus node that no longer exists rather than
50
+ // rendering an empty "(no focus node)" pane.
51
+ if (this.state.focusId && !model.nodes.some((n) => n.id === this.state.focusId)) {
52
+ this.state = { focusId: null, selectedId: null, scrollOffset: 0 };
53
+ }
54
+ }
55
+
56
+ handleInput(data: string): void {
57
+ if (matchesKey(data, "up")) return this.move(-1);
58
+ if (matchesKey(data, "down")) return this.move(1);
59
+ if (matchesKey(data, "pageUp")) return this.movePage(-1);
60
+ if (matchesKey(data, "pageDown")) return this.movePage(1);
61
+ if (matchesKey(data, "home")) {
62
+ this.state = { ...this.state, selectedId: this.rows()[0]?.id ?? null, scrollOffset: 0 };
63
+ return;
64
+ }
65
+ if (matchesKey(data, "end")) {
66
+ const r = this.rows();
67
+ const last = r[r.length - 1];
68
+ this.state = { ...this.state, selectedId: last?.id ?? null, scrollOffset: Math.max(0, r.length - this.paneRows) };
69
+ return;
70
+ }
71
+ if (matchesKey(data, "enter")) {
72
+ const rows = this.rows();
73
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
74
+ const row = idx >= 0 ? rows[idx] : undefined;
75
+ if (row && row.target && row.target !== this.state.focusId) {
76
+ this.state = { focusId: row.target, selectedId: row.target, scrollOffset: 0 };
77
+ }
78
+ return;
79
+ }
80
+ if (data === "g") {
81
+ this.onEvent?.({ type: "focusNode", id: this.state.focusId ?? "" });
82
+ return;
83
+ }
84
+ }
85
+
86
+ private move(delta: 1 | -1): void {
87
+ const rows = this.rows();
88
+ if (rows.length === 0) return;
89
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
90
+ const nextIdx = Math.max(0, Math.min(rows.length - 1, (idx < 0 ? -1 : idx) + delta));
91
+ const row = rows[nextIdx]!;
92
+ this.state = { ...this.state, selectedId: row.id };
93
+ }
94
+
95
+ private movePage(dir: 1 | -1): void {
96
+ const rows = this.rows();
97
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
98
+ const target = idx < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, idx + dir * this.paneRows));
99
+ const row = rows[target];
100
+ if (row) this.state = { ...this.state, selectedId: row.id };
101
+ }
102
+
103
+ private rows(): { id: string; target?: string }[] {
104
+ if (!this.state.focusId) return [];
105
+ const f = focusModel(this.ctx.model, this.state.focusId);
106
+ const center: { id: string; target?: string } = f.center.target ? { id: f.center.id, target: f.center.target } : { id: f.center.id };
107
+ return [center, ...f.groups.flatMap((g) => g.rows.map((r): { id: string; target?: string } => ({ id: r.id, target: r.target! })))];
108
+ }
109
+
110
+ render(width: number): string[] {
111
+ const t = this.ctx.theme;
112
+ const id = this.state.focusId;
113
+ if (!id) return [t.fg("dim", "(no focus node)")];
114
+ const f = focusModel(this.ctx.model, id);
115
+ const lines: { text: string; id?: string }[] = [];
116
+ const cm = this.rowMarker(f.center.kind, f.center.provenance);
117
+ lines.push({ text: t.bold(`${cm}${sanitizeTerminalText(f.center.label)}`) + t.fg("dim", " (focus — g/esc to exit)"), id: f.center.id });
118
+ for (const g of f.groups) {
119
+ lines.push({ text: t.fg("accent", g.heading) });
120
+ for (const r of g.rows) {
121
+ lines.push({ text: ` ${this.rowMarker(r.kind, r.provenance)}${sanitizeTerminalText(r.label)}`, id: r.id });
122
+ }
123
+ }
124
+ const selId = this.state.selectedId;
125
+ const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
126
+ const marker = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
127
+ const windowed = windowLines(lines.map((l) => l.text), selLine, this.state.scrollOffset, Math.max(5, this.paneRows), marker);
128
+ return windowed.map((l) => truncateToWidth(l, width));
129
+ }
130
+
131
+ private rowMarker(kind: string, prov: NoteSource | null): string {
132
+ const ks = kindStyle(kind as never);
133
+ if (kind === "note") {
134
+ const ps = provenanceStyle(prov);
135
+ return ps.glyph ? `${ps.glyph} ` : "";
136
+ }
137
+ return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
138
+ }
139
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * surface/health.ts — the Health surface as a Component (weave-view-tui-v2 §6).
3
+ *
4
+ * Renders `healthModel` (staleness + link health). Enter on a health row with
5
+ * a target opens that node in detail (emitted as a SurfaceEvent).
6
+ */
7
+
8
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { healthModel, sanitizeTerminalText } from "../model";
10
+ import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
11
+
12
+ export interface HealthSurfaceState {
13
+ selectedId: string | null;
14
+ scrollOffset: number;
15
+ }
16
+
17
+ export class HealthSurface implements Surface {
18
+ readonly kind = "health" as const;
19
+ private readonly ctx;
20
+ private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
21
+ state: HealthSurfaceState;
22
+ paneRows = 24;
23
+
24
+ constructor(init: SurfaceInit) {
25
+ this.ctx = init.context;
26
+ this.onEvent = init.onEvent;
27
+ this.state = { selectedId: null, scrollOffset: 0 };
28
+ }
29
+
30
+ title(): string {
31
+ return "Health";
32
+ }
33
+
34
+ setFocused(_focused: boolean): void {}
35
+
36
+ invalidate(): void {}
37
+
38
+ handleInput(data: string): void {
39
+ if (matchesKey(data, "up")) return this.move(-1);
40
+ if (matchesKey(data, "down")) return this.move(1);
41
+ if (matchesKey(data, "pageUp")) return this.movePage(-1);
42
+ if (matchesKey(data, "pageDown")) return this.movePage(1);
43
+ if (matchesKey(data, "home")) {
44
+ this.state = { ...this.state, selectedId: this.rows()[0]?.id ?? null, scrollOffset: 0 };
45
+ return;
46
+ }
47
+ if (matchesKey(data, "end")) {
48
+ const r = this.rows();
49
+ const last = r[r.length - 1];
50
+ this.state = { ...this.state, selectedId: last?.id ?? null, scrollOffset: Math.max(0, r.length - this.paneRows) };
51
+ return;
52
+ }
53
+ if (matchesKey(data, "enter")) {
54
+ const rows = this.rows();
55
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
56
+ const row = idx >= 0 ? rows[idx] : undefined;
57
+ if (row && row.target) this.onEvent?.({ type: "openDetail", id: row.target });
58
+ return;
59
+ }
60
+ }
61
+
62
+ private move(delta: 1 | -1): void {
63
+ const rows = this.rows();
64
+ if (rows.length === 0) return;
65
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
66
+ const nextIdx = Math.max(0, Math.min(rows.length - 1, (idx < 0 ? -1 : idx) + delta));
67
+ const row = rows[nextIdx]!;
68
+ this.state = { ...this.state, selectedId: row.id };
69
+ }
70
+
71
+ private movePage(dir: 1 | -1): void {
72
+ const rows = this.rows();
73
+ const idx = rows.findIndex((r) => r.id === this.state.selectedId);
74
+ const target = idx < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, idx + dir * this.paneRows));
75
+ const row = rows[target];
76
+ if (row) this.state = { ...this.state, selectedId: row.id };
77
+ }
78
+
79
+ private rows(): { id: string; target?: string }[] {
80
+ const h = healthModel(this.ctx.model);
81
+ return h.sections.flatMap((s) =>
82
+ s.rows.filter((r) => r.target).map((r): { id: string; target?: string } => ({ id: r.id, target: r.target! })),
83
+ );
84
+ }
85
+
86
+ render(width: number): string[] {
87
+ const t = this.ctx.theme;
88
+ const h = healthModel(this.ctx.model);
89
+ const lines: { text: string; id?: string }[] = [];
90
+ for (const s of h.sections) {
91
+ lines.push({ text: t.bold(s.heading) });
92
+ for (const r of s.rows) {
93
+ lines.push({ text: sanitizeTerminalText(r.text), id: r.id });
94
+ }
95
+ }
96
+ const selId = this.state.selectedId;
97
+ const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
98
+ const marker = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
99
+ const windowed = windowLines(lines.map((l) => l.text), selLine, this.state.scrollOffset, Math.max(5, this.paneRows), marker);
100
+ return windowed.map((l) => truncateToWidth(l, width));
101
+ }
102
+ }