pi-weave 0.3.7 → 0.3.9

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 (69) hide show
  1. package/README.md +17 -16
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +30 -4
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/dist/app.js +183 -52
  31. package/src/web/client/graph/Graph.tsx +23 -2
  32. package/src/web/client/graph/column.model.ts +1 -1
  33. package/src/web/client/graph/graph.model.ts +69 -7
  34. package/src/web/client/graph/renderer.ts +19 -3
  35. package/src/web/client/note/Note.tsx +171 -20
  36. package/src/web/client/note/Replay.tsx +128 -0
  37. package/src/web/client/note/drafts.ts +50 -9
  38. package/src/web/client/note/note.model.ts +17 -0
  39. package/src/web/client/note/replay.model.ts +173 -0
  40. package/src/web/client/shell/ContextRail.tsx +7 -0
  41. package/src/web/client/shell/Settings.tsx +21 -1
  42. package/src/web/client/shell/Shell.tsx +34 -10
  43. package/src/web/client/shell/keys.model.ts +20 -7
  44. package/src/web/client/shell/theme.ts +118 -1
  45. package/src/web/client/shell/workspace.css.ts +4 -0
  46. package/src/web/client/trail/TrailStrip.tsx +55 -0
  47. package/src/web/client/trail/trail.model.ts +195 -0
  48. package/src/web/client/trail/trail.ts +45 -0
  49. package/src/web/client/tree/Tree.tsx +7 -3
  50. package/src/web/client/tree/tree.model.ts +2 -5
  51. package/src/web/server/page.ts +1 -1
  52. package/src/web/server/routes.ts +83 -3
  53. package/src/web/shared/logo.ts +1 -1
  54. package/src/web/shared/view.ts +10 -4
  55. package/src/web/shared/wire.ts +74 -1
  56. package/src/core/view/health.ts +0 -160
  57. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  58. package/src/pi/viewer/tui/branding.ts +0 -14
  59. package/src/pi/viewer/tui/model.ts +0 -390
  60. package/src/pi/viewer/tui/openNote.ts +0 -11
  61. package/src/pi/viewer/tui/run.ts +0 -71
  62. package/src/pi/viewer/tui/surface/base.ts +0 -203
  63. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  64. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  65. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  66. package/src/pi/viewer/tui/surface/health.ts +0 -102
  67. package/src/pi/viewer/tui/theme.ts +0 -90
  68. package/src/pi/viewer/tui/workspace.ts +0 -53
  69. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -1,14 +0,0 @@
1
- import { visibleWidth } from "@earendil-works/pi-tui";
2
- import type { ThemeSlot } from "./theme";
3
-
4
- /** The TUI uses one terminal-safe mark. */
5
- export const MARK_GLYPH = "◈";
6
- export const WORDMARK = "weave view";
7
-
8
- export function renderMark(
9
- theme: { fg: (slot: ThemeSlot, text: string) => string },
10
- width: number,
11
- ): string {
12
- const mark = theme.fg("accent", MARK_GLYPH);
13
- return visibleWidth(mark) <= width ? mark : "";
14
- }
@@ -1,390 +0,0 @@
1
- /**
2
- * TUI-specific view-model for the weave-view explorer (weave-view-tui-design
3
- * §5, §10.1).
4
- *
5
- * The portable projections (`treeRows`, `detailModel`, `focusModel`,
6
- * `healthModel`, …) now live in `src/core/view` so the browser workspace can
7
- * share them (weave-workspace §3). What stays here is what is genuinely about
8
- * a terminal: the explorer's `ExplorerState`/`Action`/`reduce` state machine,
9
- * terminal-escape sanitising, and the theme glue.
10
- *
11
- * Everything moved is re-exported below, so TUI files keep importing from
12
- * `./model`.
13
- */
14
-
15
- import type { NoteSource } from "../../../core/types";
16
- import type { SelectableRow, TreeRow } from "../../../core/view";
17
- import { MAX_FILTER_LEN, PROVENANCE_CYCLE, provenanceStyle, type ProvenanceStyle } from "./theme";
18
-
19
- // ---------------------------------------------------------------------------
20
- // sanitizeTerminalText — the TUI's XSS analog (design §9.3)
21
- // ---------------------------------------------------------------------------
22
-
23
- /** Strip terminal-escape/control sequences from disk/user content before
24
- * styling or handing to Markdown: ESC (0x1b), CSI (0x9b), BEL (0x07), and
25
- * other C0 controls except newline (0x0a) and tab (0x09). */
26
- export function sanitizeTerminalText(s: string): string {
27
- // eslint-disable-next-line no-control-regex
28
- return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
29
- }
30
-
31
- // ---------------------------------------------------------------------------
32
- // ExplorerState + reduce — key→state transitions (design §6, §10.1)
33
- // ---------------------------------------------------------------------------
34
-
35
- export type Surface = "tree" | "detail" | "focus" | "health";
36
-
37
- export interface ExplorerState {
38
- surface: Surface;
39
- /** Inline search sub-mode (only meaningful on the tree surface). */
40
- searching: boolean;
41
- /** Selected node id (source of truth; the cursor index is derived). */
42
- selectedId: string | null;
43
- /** Node pinned for focus mode. */
44
- focusId: string | null;
45
- /** Node open in detail. */
46
- detailId: string | null;
47
- /** Tree expansion (when no filter is active). */
48
- expanded: Set<string>;
49
- showInternals: boolean;
50
- provFilter: NoteSource | null;
51
- query: string;
52
- helpOpen: boolean;
53
- refreshing: boolean;
54
- /** Bumped on every mutation so the render cache can key off it. */
55
- version: number;
56
- /** Scroll offset (top visible row index) for the current surface. */
57
- scrollOffset: number;
58
- }
59
-
60
- export function initialState(roots: readonly string[]): ExplorerState {
61
- const expanded = new Set<string>();
62
- for (const r of roots) expanded.add(r);
63
- const selectedId = roots[0] ?? null;
64
- return {
65
- surface: "tree",
66
- searching: false,
67
- selectedId,
68
- focusId: null,
69
- detailId: null,
70
- expanded,
71
- showInternals: false,
72
- provFilter: null,
73
- query: "",
74
- helpOpen: false,
75
- refreshing: false,
76
- version: 0,
77
- scrollOffset: 0,
78
- };
79
- }
80
-
81
- export type Action =
82
- | { type: "up" }
83
- | { type: "down" }
84
- | { type: "pageUp" }
85
- | { type: "pageDown" }
86
- | { type: "home" }
87
- | { type: "end" }
88
- | { type: "left" }
89
- | { type: "right" }
90
- | { type: "enter" }
91
- | { type: "searchStart" }
92
- | { type: "searchChar"; ch: string }
93
- | { type: "searchBackspace" }
94
- | { type: "searchCommit" }
95
- | { type: "cycleProvenance" }
96
- | { type: "toggleInternals" }
97
- | { type: "focus" }
98
- | { type: "focusExit" }
99
- | { type: "surfaceTree" }
100
- | { type: "surfaceHealth" }
101
- | { type: "refresh" }
102
- | { type: "refreshDone" }
103
- | { type: "toggleHelp" }
104
- | { type: "quit" }
105
- | { type: "esc" };
106
-
107
- /** Context reduce needs for movement: the selectable rows of the current
108
- * surface (in display order) and the viewport window size. */
109
- export interface ReduceCtx {
110
- rows: readonly SelectableRow[];
111
- window: number;
112
- }
113
-
114
- function bump(s: ExplorerState): ExplorerState {
115
- return { ...s, version: s.version + 1 };
116
- }
117
-
118
- function clampIndex(idx: number, len: number): number {
119
- if (len === 0) return -1;
120
- if (idx >= len) return len - 1;
121
- return idx;
122
- }
123
-
124
- function scrollForSelection(idx: number, window: number, prev: number): number {
125
- if (window <= 0) return 0;
126
- // keep selection visible: offset <= idx < offset + window
127
- if (idx < prev) return idx;
128
- if (idx >= prev + window) return idx - window + 1;
129
- return prev;
130
- }
131
-
132
- function provenanceCycleNext(current: NoteSource | null): NoteSource | null {
133
- const i = PROVENANCE_CYCLE.indexOf(current);
134
- const next = PROVENANCE_CYCLE[(i + 1) % PROVENANCE_CYCLE.length];
135
- return next ?? null;
136
- }
137
-
138
- /**
139
- * Apply an action to the explorer state. Pure: returns a new state.
140
- *
141
- * Movement uses `ctx.rows` (the current surface's selectable rows) and
142
- * `ctx.window` (viewport height) to clamp the cursor and adjust scroll.
143
- * The component builds `ctx.rows` from the surface's model function.
144
- */
145
- export function reduce(state: ExplorerState, action: Action, ctx: ReduceCtx = { rows: [], window: 24 }): ExplorerState {
146
- const rows = ctx.rows;
147
- const len = rows.length;
148
- const currentIdx = state.selectedId ? rows.findIndex((r) => r.id === state.selectedId || r.target === state.selectedId) : -1;
149
-
150
- switch (action.type) {
151
- case "up":
152
- case "down":
153
- case "pageUp":
154
- case "pageDown":
155
- case "home":
156
- case "end": {
157
- if (len === 0) return state;
158
- let nextIdx: number;
159
- if (action.type === "up") nextIdx = currentIdx <= 0 ? 0 : currentIdx - 1;
160
- else if (action.type === "down") nextIdx = currentIdx < 0 ? 0 : Math.min(currentIdx + 1, len - 1);
161
- else if (action.type === "pageUp") nextIdx = currentIdx < 0 ? 0 : Math.max(0, currentIdx - ctx.window);
162
- else if (action.type === "pageDown") nextIdx = currentIdx < 0 ? 0 : Math.min(len - 1, currentIdx + ctx.window);
163
- else if (action.type === "home") nextIdx = 0;
164
- else nextIdx = len - 1; // end
165
- nextIdx = clampIndex(nextIdx, len);
166
- const row = rows[nextIdx];
167
- if (!row) return state;
168
- const selectedId = row.target ?? row.id;
169
- const scrollOffset = scrollForSelection(nextIdx, ctx.window, state.scrollOffset);
170
- return bump({ ...state, selectedId, scrollOffset });
171
- }
172
-
173
- case "left":
174
- case "right": {
175
- if (state.surface !== "tree") return state;
176
- if (!state.selectedId) return state;
177
- // operate on the tree: find the row for selectedId
178
- const treeRowIdx = rows.findIndex((r) => r.id === state.selectedId);
179
- const treeRow = treeRowIdx >= 0 ? (rows[treeRowIdx] as TreeRow | undefined) : undefined;
180
- const isExpanded = state.expanded.has(state.selectedId);
181
- if (action.type === "right") {
182
- if (treeRow && treeRow.hasKids && !isExpanded) {
183
- const expanded = new Set(state.expanded).add(state.selectedId);
184
- return bump({ ...state, expanded });
185
- }
186
- if (treeRow && isExpanded) {
187
- // move to first child
188
- const child = rows[treeRowIdx + 1];
189
- if (child) return bump({ ...state, selectedId: child.id, scrollOffset: scrollForSelection(treeRowIdx + 1, ctx.window, state.scrollOffset) });
190
- }
191
- return state;
192
- }
193
- // left: collapse, or jump to parent
194
- if (treeRow && isExpanded) {
195
- const expanded = new Set(state.expanded);
196
- expanded.delete(state.selectedId);
197
- return bump({ ...state, expanded });
198
- }
199
- // jump to parent: the nearest preceding row with smaller depth
200
- for (let i = treeRowIdx - 1; i >= 0; i--) {
201
- const r = rows[i] as TreeRow | undefined;
202
- if (r && r.depth < (treeRow?.depth ?? 0)) {
203
- return bump({ ...state, selectedId: r.id, scrollOffset: scrollForSelection(i, ctx.window, state.scrollOffset) });
204
- }
205
- }
206
- return state;
207
- }
208
-
209
- case "enter": {
210
- if (state.searching) {
211
- // commit search: keep filter, exit search mode
212
- return bump({ ...state, searching: false, scrollOffset: 0 });
213
- }
214
- if (!state.selectedId) return state;
215
- const row = currentIdx >= 0 ? rows[currentIdx] : undefined;
216
- if (state.surface === "tree") {
217
- return bump({ ...state, surface: "detail", detailId: state.selectedId, scrollOffset: 0 });
218
- }
219
- if (state.surface === "detail") {
220
- // jump to the selected link/backlink target
221
- if (row && row.target) {
222
- return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
223
- }
224
- return state;
225
- }
226
- if (state.surface === "focus") {
227
- // re-center on the selected neighbor
228
- if (row && row.target) {
229
- return bump({ ...state, focusId: row.target, selectedId: row.target, scrollOffset: 0 });
230
- }
231
- return state;
232
- }
233
- if (state.surface === "health") {
234
- if (row && row.target) {
235
- return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
236
- }
237
- return state;
238
- }
239
- return state;
240
- }
241
-
242
- case "searchStart": {
243
- if (state.searching) return state;
244
- if (state.surface === "detail") return state;
245
- return bump({ ...state, searching: true });
246
- }
247
-
248
- case "searchChar": {
249
- if (!state.searching) return state;
250
- const q = (state.query + action.ch).slice(0, MAX_FILTER_LEN);
251
- return bump({ ...state, query: q, scrollOffset: 0 });
252
- }
253
-
254
- case "searchBackspace": {
255
- if (!state.searching) return state;
256
- return bump({ ...state, query: state.query.slice(0, -1), scrollOffset: 0 });
257
- }
258
-
259
- case "searchCommit": {
260
- if (!state.searching) return state;
261
- return bump({ ...state, searching: false, scrollOffset: 0 });
262
- }
263
-
264
- case "esc": {
265
- // precedence: search > surface-exit > quit
266
- if (state.searching) {
267
- return bump({ ...state, searching: false, query: "", scrollOffset: 0 });
268
- }
269
- if (state.surface === "detail") {
270
- return bump({ ...state, surface: "tree", scrollOffset: 0 });
271
- }
272
- if (state.surface === "focus") {
273
- return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
274
- }
275
- if (state.surface === "health") {
276
- return bump({ ...state, surface: "tree", scrollOffset: 0 });
277
- }
278
- // tree → quit (component resolves done(null))
279
- return bump({ ...state });
280
- }
281
-
282
- case "quit": {
283
- return bump({ ...state });
284
- }
285
-
286
- case "cycleProvenance": {
287
- if (state.searching) return state;
288
- return bump({ ...state, provFilter: provenanceCycleNext(state.provFilter), scrollOffset: 0 });
289
- }
290
-
291
- case "toggleInternals": {
292
- if (state.searching) return state;
293
- return bump({ ...state, showInternals: !state.showInternals, scrollOffset: 0 });
294
- }
295
-
296
- case "focus": {
297
- if (state.searching) return state;
298
- if (!state.selectedId) return state;
299
- return bump({ ...state, surface: "focus", focusId: state.selectedId, scrollOffset: 0 });
300
- }
301
-
302
- case "focusExit": {
303
- return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
304
- }
305
-
306
- case "surfaceTree": {
307
- if (state.searching) return state;
308
- return bump({ ...state, surface: "tree", scrollOffset: 0 });
309
- }
310
-
311
- case "surfaceHealth": {
312
- if (state.searching) return state;
313
- return bump({ ...state, surface: "health", scrollOffset: 0 });
314
- }
315
-
316
- case "refresh": {
317
- return bump({ ...state, refreshing: true });
318
- }
319
-
320
- case "refreshDone": {
321
- return bump({ ...state, refreshing: false });
322
- }
323
-
324
- case "toggleHelp": {
325
- if (state.searching) return state;
326
- return bump({ ...state, helpOpen: !state.helpOpen });
327
- }
328
- }
329
- // Exhaustive over Action; unreachable for valid inputs.
330
- return state;
331
- }
332
-
333
- /**
334
- * Merge explorer state across a refresh (design §6): keep the expanded set,
335
- * filter, surface, and selected node id. A selected/expanded id that no
336
- * longer exists drops out; selection falls back to the first root.
337
- */
338
- export function mergeAfterRefresh(prev: ExplorerState, nextRoots: readonly string[]): ExplorerState {
339
- let selectedId = prev.selectedId;
340
- if (selectedId !== null && !nextRoots.includes(selectedId)) {
341
- // selectedId may be a non-root; the component re-resolves it against the
342
- // new tree rows. Roots fallback only when it is null.
343
- }
344
- if (selectedId === null) selectedId = nextRoots[0] ?? null;
345
- return {
346
- ...prev,
347
- refreshing: false,
348
- selectedId,
349
- version: prev.version + 1,
350
- scrollOffset: 0,
351
- };
352
- }
353
-
354
- // ---------------------------------------------------------------------------
355
- // Re-exports — the portable view-models, kept importable from `./model` so no
356
- // TUI file needs to know they moved to src/core/view.
357
- // ---------------------------------------------------------------------------
358
-
359
- export {
360
- countProvenance,
361
- degreeOf,
362
- deriveBacklinks,
363
- detailModel,
364
- focusModel,
365
- focusNeighborhood,
366
- formatTreeMeta,
367
- graphRoots,
368
- healthModel,
369
- listLabel,
370
- relTime,
371
- treeEmptyHint,
372
- treeRows,
373
- type DetailLinkRow,
374
- type DetailMetaRow,
375
- type DetailModel,
376
- type FocusGroup,
377
- type FocusModel,
378
- type FocusRow,
379
- type HealthModel,
380
- type HealthRow,
381
- type HealthSection,
382
- type ProvenanceCounts,
383
- type SelectableRow,
384
- type TreeMeta,
385
- type TreeRow,
386
- type TreeState,
387
- } from "../../../core/view";
388
-
389
- // Re-export theme helpers some tests reach for.
390
- export { provenanceStyle, type ProvenanceStyle };
@@ -1,11 +0,0 @@
1
- /**
2
- * Compatibility re-export.
3
- *
4
- * The implementation moved to `src/core/openInEditor.ts` (weave-workspace
5
- * §5.3): the browser workspace's `POST /api/open` needs it and
6
- * `src/web/server/**` may not import `src/pi`. Keeping this module as a
7
- * one-line re-export means `run.ts` and every TUI test kept working without
8
- * an edit; there is nothing TUI-specific left to live here.
9
- */
10
-
11
- export { openNoteCommand, openNoteInEditor } from "../../../core/openInEditor";
@@ -1,71 +0,0 @@
1
- /**
2
- * runWeaveViewTui — wires the workspace explorer into a pi session
3
- * (weave-view-tui-design §2, §4.1, §3.2).
4
- *
5
- * Guards (interactive terminal only), builds the graph from disk in the
6
- * handler, then hands a ready workspace explorer to `ctx.ui.custom` so the
7
- * explorer owns input for its whole lifetime. After `done(null)` resolves,
8
- * the workspace status line is refreshed. Dependencies are injected so the
9
- * component never touches a real terminal directly.
10
- */
11
-
12
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
- import {
14
- buildCurrentGraph,
15
- readNoteForView,
16
- readOkfFileForView,
17
- resolveVaultRoot,
18
- } from "../../../core";
19
- import { openNoteInEditor } from "./openNote";
20
- import { WeaveWorkspace } from "./workspaceRoot";
21
- import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./surface/base";
22
- import { getWorkspaceStatus, formatStatusLine } from "../../../core";
23
-
24
- /** Open the in-terminal knowledge explorer. Returns when the explorer closes. */
25
- export async function runWeaveViewTui(ctx: ExtensionCommandContext): Promise<void> {
26
- // Guard: the TUI needs an interactive terminal (design §2).
27
- if (!ctx.hasUI || ctx.mode !== "tui") {
28
- ctx.ui.notify(
29
- "pi-weave: '/weave-view' needs an interactive terminal to open the in-terminal explorer.",
30
- "warning",
31
- );
32
- return;
33
- }
34
-
35
- const cwd = ctx.cwd;
36
- const vaultRoot = resolveVaultRoot();
37
- const model = await buildCurrentGraph(cwd, vaultRoot);
38
-
39
- const loaders: WeaveLoaders = {
40
- loadNote: (slug) => readNoteForView(vaultRoot, slug),
41
- loadOkf: (rel) => readOkfFileForView(cwd, rel),
42
- openNote: (slug) => openNoteInEditor(vaultRoot, slug),
43
- rebuild: () => buildCurrentGraph(cwd, vaultRoot),
44
- };
45
-
46
- await ctx.ui.custom(
47
- (tui, theme, _keybindings, done) => {
48
- const explorer = new WeaveWorkspace({
49
- model,
50
- theme: theme as unknown as WeaveTheme,
51
- tui: tui as unknown as WeaveTui,
52
- loaders,
53
- done,
54
- rows: tui.terminal.rows,
55
- });
56
- return explorer;
57
- },
58
- // Full-screen, not an overlay (design §4.1).
59
- );
60
-
61
- // Refresh the status line after close (a /weave-scan may have landed meanwhile).
62
- const status = await getWorkspaceStatus(ctx.cwd);
63
- let theme: { fg?: (slot: string, text: string) => string } | undefined;
64
- try {
65
- theme = (ctx.ui as unknown as { theme?: { fg?: (slot: string, text: string) => string } })?.theme;
66
- } catch {
67
- // ignore
68
- }
69
- const indicator = theme?.fg ? theme.fg("dim", "○") : "○";
70
- ctx.ui.setStatus("weave", `${indicator} ${formatStatusLine(status)}`);
71
- }
@@ -1,203 +0,0 @@
1
- /**
2
- * surface/base.ts — the shared Pane<Surface> wrapper (weave-view-tui-v2 §6).
3
- *
4
- * A **Surface** is a harness-free, self-owned component: it holds its own
5
- * selection/scroll/filter/expansion state, renders its lines, and handles its
6
- * own keys. Surfaces share the GraphModel, BodyStore, and loaders via a
7
- * `SurfaceContext`. A **Pane** is a `Component` that hosts a Surface inside a
8
- * bordered Box with a title and a focus ring (accent when active, dim `line`
9
- * when inactive — conveyed by border + a leading `◆`, not color alone, per
10
- * §10).
11
- *
12
- * The pane owns scrolling via the injected scroll/offset on the surface; the
13
- * workspace root routes input to the single active pane's surface.
14
- */
15
-
16
- import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
17
- import type { GraphModel } from "../../../../core/graph/model";
18
- import { BodyStore } from "../bodyStore";
19
- import type { ViewNote } from "../../../../core/graph/current";
20
- import type { SurfaceKind } from "../workspace";
21
- import type { ThemeSlot } from "../theme";
22
-
23
- /** Minimal theme surface shared by the workspace root and its surfaces. */
24
- export interface WeaveTheme {
25
- fg(slot: ThemeSlot, text: string): string;
26
- bg(slot: "selectedBg", text: string): string;
27
- bold(text: string): string;
28
- }
29
-
30
- /** Minimal TUI surface shared by the workspace root and its adapter. */
31
- export interface WeaveTui {
32
- requestRender(force?: boolean): void;
33
- terminal: { rows: number; columns: number };
34
- }
35
-
36
- /** Injected readers/actions bound to the vault and repository by run.ts. */
37
- export interface WeaveLoaders {
38
- loadNote: (slug: string) => Promise<ViewNote | null>;
39
- loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
40
- openNote: (slug: string) => Promise<boolean>;
41
- rebuild: () => Promise<GraphModel>;
42
- }
43
-
44
- /** A cross-pane navigation request a surface emits (resolved by the root). */
45
- export type SurfaceEvent =
46
- | { type: "openDetail"; id: string }
47
- | { type: "focusNode"; id: string }
48
- | { type: "openEditor"; id: string };
49
-
50
- /** A surface emits events (e.g. "open detail") that the workspace root resolves. */
51
- export type SurfaceEventHandler = (event: SurfaceEvent) => void;
52
-
53
- /** Shared, session-level deps every surface reads. */
54
- export interface SurfaceContext {
55
- model: GraphModel;
56
- theme: WeaveTheme;
57
- loaders: WeaveLoaders;
58
- bodies: BodyStore;
59
- /** Reference clock (epoch ms); defaults to Date.now at the root. */
60
- now: () => number;
61
- }
62
-
63
- /** A surface can also be constructed with a render callback (test seam). */
64
- export interface SurfaceInit {
65
- context: SurfaceContext;
66
- /** Emit a cross-pane navigation event. */
67
- onEvent?: SurfaceEventHandler;
68
- }
69
-
70
- /** What a surface component must expose on top of the pi-tui Component contract. */
71
- export interface Surface extends Component {
72
- /** The surface kind this hosts. */
73
- readonly kind: SurfaceKind;
74
- /** Called by the workspace root when focus moves to/from this surface. */
75
- setFocused(focused: boolean): void;
76
- /** The pane title (surface name), shown in the border. */
77
- title(): string;
78
- /**
79
- * Called after a refresh rebuilds the model, so a surface bound to a node
80
- * that no longer exists can rebind to a survivor instead of showing
81
- * "(not found)". Optional — surfaces with no node binding omit it.
82
- */
83
- rebind?(model: GraphModel): void;
84
- }
85
-
86
- /** Minimal theme surface the pane chrome needs. */
87
- export interface PaneTheme {
88
- fg(slot: ThemeSlot, text: string): string;
89
- bold(text: string): string;
90
- }
91
-
92
- /** A selectable row within a rendered surface (mirrors model.SelectableRow). */
93
- export interface PaneRow {
94
- id: string;
95
- /** Node id a row jumps to on enter (links/backlinks/neighbors). */
96
- target?: string;
97
- }
98
-
99
- /** The render result of a surface: lines plus the selectable rows. */
100
- export interface SurfaceRender {
101
- lines: string[];
102
- rows: PaneRow[];
103
- }
104
-
105
- /** A generic selectable, windowed body builder used by the surfaces. */
106
- export function windowLines(
107
- lines: string[],
108
- selLine: number,
109
- scrollOffset: number,
110
- window: number,
111
- marker: (text: string) => string,
112
- ): string[] {
113
- const win = Math.max(1, window);
114
- let offset = scrollOffset;
115
- if (selLine >= 0) {
116
- if (selLine < offset) offset = selLine;
117
- else if (selLine >= offset + win) offset = selLine - win + 1;
118
- }
119
- offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - win)));
120
- const out: string[] = [];
121
- const end = Math.min(lines.length, offset + win);
122
- for (let i = offset; i < end; i++) {
123
- const ln = lines[i]!;
124
- out.push(i === selLine ? marker(ln) : ` ${ln}`);
125
- }
126
- return out;
127
- }
128
-
129
- /**
130
- * Pane — a bordered Box hosting a Surface. Renders a title + focus ring on
131
- * the first line, then the surface's (windowed) lines. This is the v1 single
132
- * pane chrome lifted into a reusable wrapper; the workspace root instantiates
133
- * one Pane per pane node.
134
- */
135
- export class Pane implements Component {
136
- private readonly surface: Surface;
137
- private readonly theme: PaneTheme;
138
- private readonly borderFn: (slot: ThemeSlot, text: string) => string;
139
- /** Current viewport height in rows (for windowing). */
140
- rows = 24;
141
- focused = false;
142
- private cacheKey = "";
143
-
144
- constructor(
145
- surface: Surface,
146
- theme: PaneTheme,
147
- // Bind through the theme object: a bare `theme.fg` default would detach
148
- // `this`, so the real pi theme's `fg` (which reads `this.fgColors.get(...)`)
149
- // would crash with "Cannot read properties of undefined (reading 'get')".
150
- borderFn: (slot: ThemeSlot, text: string) => string = (slot, text) => theme.fg(slot, text),
151
- ) {
152
- this.surface = surface;
153
- this.theme = theme;
154
- this.borderFn = borderFn;
155
- }
156
-
157
- get surfaceComponent(): Surface {
158
- return this.surface;
159
- }
160
-
161
- setFocused(focused: boolean): void {
162
- this.focused = focused;
163
- this.surface.setFocused(focused);
164
- }
165
-
166
- invalidate(): void {
167
- this.cacheKey = "";
168
- this.surface.invalidate();
169
- }
170
-
171
- render(width: number): string[] {
172
- const inner = Math.max(2, width - 2);
173
- // The surface already windows + selection-marks its own lines (each
174
- // surface owns its scroll/selection state). The pane only wraps them in a
175
- // border and PADS to fill its allocated height so the multi-pane view looks clean —
176
- // it must NOT re-window (that double-applies the indent/marker and corrupts
177
- // the layout). Each body line is sliced/padded to `inner` so borders align.
178
- const surfaceLines = this.surface.render(inner);
179
- const title = this.surface.title();
180
- const active = this.focused;
181
- const borderSlot = active ? "accent" : "dim";
182
- const marker = active ? "◆ " : " ";
183
- const titleText = `${marker}${title} `;
184
- const top = `${this.borderFn(borderSlot, "┌")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┐")}`;
185
- const titleVis = visibleWidth(titleText);
186
- const titleLine = `${this.borderFn(borderSlot, "│")}${this.theme.bold(titleText)}${" ".repeat(Math.max(0, inner - titleVis))}${this.borderFn(borderSlot, "│")}`;
187
- // top + title + bottom = 3 fixed lines; body fills the rest so both panes
188
- // in a multi-pane render the same height.
189
- const bodyRows = Math.max(0, this.rows - 3);
190
- const out = [top, titleLine];
191
- for (let i = 0; i < bodyRows; i++) {
192
- const ln = surfaceLines[i] ?? "";
193
- const slice = visibleWidth(ln) > inner ? truncateToWidth(ln, inner) : ln;
194
- const pad = Math.max(0, inner - visibleWidth(slice));
195
- out.push(`${this.borderFn(borderSlot, "│")}${slice}${" ".repeat(pad)}${this.borderFn(borderSlot, "│")}`);
196
- }
197
- const bottom = `${this.borderFn(borderSlot, "└")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┘")}`;
198
- out.push(bottom);
199
- return out;
200
- }
201
- }
202
-
203
- export type { Component, SurfaceKind };