pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,381 +1,53 @@
1
- /**
2
- * workspace.ts — the pure split-tree workspace model (weave-view-tui-v2 §4).
3
- *
4
- * A workspace is a recursive tree of splits (HStack rows / VStack columns)
5
- * and panes. Each pane is a surface instance with its own selection/scroll/
6
- * filter state (held by the pane component, not here); this module models the
7
- * *structure* only: the split tree, pane ids, per-pane surface + bound node,
8
- * focus (active pane), resize weights, and responsive collapse.
9
- *
10
- * This layer is harness-free (imports only core types) so every operation is
11
- * unit-tested without a terminal — mirroring v1's model.ts posture (§11).
12
- */
13
-
14
1
  import type { GraphModel } from "../../../core/graph/model";
15
2
  import { graphRoots } from "./model";
16
3
 
17
- /** The four surface kinds a pane can host. */
18
4
  export type SurfaceKind = "explore" | "detail" | "focus" | "health";
19
5
 
20
6
  export interface PaneNode {
21
- type: "pane";
22
- /** Stable pane id. */
23
7
  id: string;
24
8
  surface: SurfaceKind;
25
- /** Node id bound for detail/focus panes; null until opened. */
26
9
  nodeId: string | null;
27
10
  }
28
11
 
29
- /** Split direction. `row` → HStack (side-by-side); `column` → VStack (stacked). */
30
- export type SplitDir = "row" | "column";
31
-
32
- export interface SplitNode {
33
- type: "split";
34
- direction: SplitDir;
35
- /** Flex weights per child (drives the pi-tui Stack grow values). */
36
- sizes: number[];
37
- children: WorkspaceNode[];
38
- }
39
-
40
- export type WorkspaceNode = PaneNode | SplitNode;
41
-
42
12
  export interface Workspace {
43
13
  name: string;
44
- root: WorkspaceNode;
45
- /** The single active (focus-owning) pane id. */
14
+ panes: PaneNode[];
46
15
  activePaneId: string;
47
16
  }
48
17
 
49
- /** Axis a resize/move direction acts on. */
50
- export type Axis = "row" | "column";
51
-
52
- /** Split direction a `\`/`|` command produces. */
53
- export type SplitRequest = "vertical" | "horizontal";
54
-
55
- let nextPaneId = 1;
56
- /** Fresh, collision-free pane id (test seam resets via resetWorkspaceIds). */
57
- export function newPaneId(): string {
58
- return `pane${nextPaneId++}`;
59
- }
60
- export function resetWorkspaceIds(): void {
61
- nextPaneId = 1;
62
- }
63
-
64
- export function paneNode(surface: SurfaceKind, nodeId: string | null = null): PaneNode {
65
- return { type: "pane", id: newPaneId(), surface, nodeId };
66
- }
67
-
68
- export function splitNode(direction: SplitDir, children: WorkspaceNode[], sizes?: number[]): SplitNode {
69
- const s = sizes ?? children.map(() => 1);
70
- return { type: "split", direction, children, sizes: s.length === children.length ? s : children.map(() => 1) };
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Navigation / collection
75
- // ---------------------------------------------------------------------------
76
-
77
- /** All panes in layout (render) order — the focus-cycle order. */
78
- export function collectPanes(node: WorkspaceNode): PaneNode[] {
79
- if (node.type === "pane") return [node];
80
- const out: PaneNode[] = [];
81
- for (const c of node.children) out.push(...collectPanes(c));
82
- return out;
18
+ function pane(surface: SurfaceKind, nodeId: string | null = null): PaneNode {
19
+ return { id: surface, surface, nodeId };
83
20
  }
84
21
 
85
22
  export function workspacePanes(ws: Workspace): PaneNode[] {
86
- return collectPanes(ws.root);
87
- }
88
-
89
- export function findPane(node: WorkspaceNode, id: string): PaneNode | null {
90
- if (node.type === "pane") return node.id === id ? node : null;
91
- for (const c of node.children) {
92
- const found = findPane(c, id);
93
- if (found) return found;
94
- }
95
- return null;
23
+ return ws.panes;
96
24
  }
97
25
 
98
- export function countPanes(ws: Workspace): number {
99
- return workspacePanes(ws).length;
26
+ export function findPane(panes: PaneNode[], id: string): PaneNode | null {
27
+ return panes.find((item) => item.id === id) ?? null;
100
28
  }
101
29
 
102
- /** Cycle the active pane in layout order; `dir` is +1 (next) or -1 (prev). */
103
30
  export function focusNext(ws: Workspace, dir: 1 | -1): Workspace {
104
- const panes = workspacePanes(ws);
105
- if (panes.length <= 1) return ws;
106
- const idx = panes.findIndex((p) => p.id === ws.activePaneId);
107
- const next = panes[(idx + dir + panes.length) % panes.length];
108
- if (!next) return ws;
109
- return { ...ws, activePaneId: next.id };
31
+ if (ws.panes.length <= 1) return ws;
32
+ const index = ws.panes.findIndex((item) => item.id === ws.activePaneId);
33
+ const next = ws.panes[(index + dir + ws.panes.length) % ws.panes.length];
34
+ return next ? { ...ws, activePaneId: next.id } : ws;
110
35
  }
111
36
 
112
- // ---------------------------------------------------------------------------
113
- // Split / close / swap
114
- // ---------------------------------------------------------------------------
115
-
116
- /**
117
- * Split a pane in place, producing `dir`:
118
- * - "vertical" → a column split (new pane below the original).
119
- * - "horizontal" → a row split (new pane to the right of the original).
120
- * The new pane is created with the same surface and empty selection, and
121
- * becomes active. Returns the new workspace.
122
- */
123
- export function split(ws: Workspace, id: string, dir: SplitRequest, surface: SurfaceKind = "explore"): Workspace {
124
- const parent = findParent(ws.root, id);
125
- const holder = parent?.children ?? [ws.root];
126
- const idx = holder.findIndex((c) => c.type === "pane" && c.id === id);
127
- if (idx < 0) return ws;
128
- const target = holder[idx] as PaneNode;
129
-
130
- const fresh = paneNode(surface);
131
- const newSplit = splitNode(
132
- dir === "vertical" ? "column" : "row",
133
- [target, fresh],
134
- [1, 1],
135
- );
136
-
137
- if (!parent) {
138
- return { ...ws, root: newSplit, activePaneId: fresh.id };
139
- }
140
- const children = parent.children.slice();
141
- children[idx] = newSplit;
142
- const nextRoot = replaceChild(ws.root, parent, children);
143
- return { ...ws, root: nextRoot, activePaneId: fresh.id };
144
- }
145
-
146
- /** Swap two panes' surface + bound node within a split (the `move` command). */
147
- export function movePane(ws: Workspace, id: string, dir: Axis): Workspace {
148
- const parent = findParent(ws.root, id);
149
- if (!parent || parent.direction !== dir) return ws;
150
- const idx = parent.children.findIndex((c) => c.type === "pane" && c.id === id);
151
- if (idx < 0) return ws;
152
- // swap with the adjacent pane (right sibling if present, else left)
153
- const neighborIdx = parent.children[idx + 1] ? idx + 1 : idx - 1;
154
- const neighbor = neighborIdx !== undefined ? parent.children[neighborIdx] : undefined;
155
- if (neighbor === undefined || neighbor.type !== "pane") return ws;
156
- const children = parent.children.slice();
157
- const a = children[idx] as PaneNode;
158
- const b = neighbor;
159
- children[idx] = { ...a, surface: b.surface, nodeId: b.nodeId };
160
- children[neighborIdx] = { ...b, surface: a.surface, nodeId: a.nodeId };
161
- return { ...ws, root: replaceChild(ws.root, parent, children) };
162
- }
163
-
164
- /**
165
- * Close the active pane. If it was the last pane, keep one empty pane (don't
166
- * quit). The focus moves to the pane that replaced it (nearest neighbor).
167
- */
168
- export function close(ws: Workspace, id: string): Workspace {
169
- const parent = findParent(ws.root, id);
170
- if (!parent) {
171
- // single-pane root or id not present: keep one empty pane (don't quit)
172
- if (ws.root.type === "pane") {
173
- return {
174
- ...ws,
175
- root: paneNode("explore"),
176
- activePaneId: newPaneId(),
177
- };
178
- }
179
- return ws; // id not found
180
- }
181
- const idx = parent.children.findIndex((c) => c.type === "pane" && c.id === id);
182
- if (idx < 0) return ws;
183
- const children = parent.children.slice();
184
- children.splice(idx, 1);
185
- const nextRoot = collapseEmptySplits(replaceChild(ws.root, parent, children));
186
- const panes = collectPanes(nextRoot);
187
- const nextActive = panes[Math.min(idx, panes.length - 1)]?.id ?? newPaneId();
188
- return { ...ws, root: nextRoot, activePaneId: nextActive };
189
- }
190
-
191
- /** Remove a split node that has a single child, unwrapping it (fixes close). */
192
- export function collapseEmptySplits(node: WorkspaceNode): WorkspaceNode {
193
- if (node.type === "pane") return node;
194
- const children = node.children.map(collapseEmptySplits);
195
- if (children.length === 1) return children[0]!;
196
- return { ...node, children };
197
- }
198
-
199
- /** Set a pane's surface in place (the e/d/f/h surface swap). */
200
- export function setPaneSurface(ws: Workspace, id: string, surface: SurfaceKind): Workspace {
201
- return { ...ws, root: setPaneNodeSurface(ws.root, id, surface) };
202
- }
203
-
204
- function setPaneNodeSurface(node: WorkspaceNode, id: string, surface: SurfaceKind): WorkspaceNode {
205
- if (node.type === "pane") return node.id === id ? { ...node, surface } : node;
206
- return { ...node, children: node.children.map((c) => setPaneNodeSurface(c, id, surface)) };
207
- }
208
-
209
- // ---------------------------------------------------------------------------
210
- // Resize
211
- // ---------------------------------------------------------------------------
212
-
213
- /**
214
- * Resize the active pane along `axis` by `delta` weight units, adjusting it
215
- * against its sibling within the nearest split on that axis. Weights are
216
- * clamped to [1, 100]. Returns the same workspace when there is no sibling on
217
- * the axis.
218
- */
219
- export function resize(ws: Workspace, id: string, axis: Axis, delta: number): Workspace {
220
- const parent = findParent(ws.root, id);
221
- if (!parent || parent.direction !== axis) return ws;
222
- const idx = parent.children.findIndex((c) => c.type === "pane" && c.id === id);
223
- if (idx < 0) return ws;
224
- // neighbor to adjust: shrink/grow against the next sibling if present, else prev
225
- const neighborIdx: number | undefined = parent.children[idx + 1] ? idx + 1 : parent.children[idx - 1] ? idx - 1 : undefined;
226
- if (neighborIdx === undefined) return ws;
227
- const sizes = parent.sizes.slice();
228
- const cur = sizes[idx] ?? 1;
229
- const nbr = sizes[neighborIdx] ?? 1;
230
- const d = Math.sign(delta) * Math.min(3, Math.abs(delta));
231
- const nextCur = Math.max(1, Math.min(100, cur + d));
232
- const nextNbr = Math.max(1, Math.min(100, nbr - d));
233
- sizes[idx] = nextCur;
234
- sizes[neighborIdx] = nextNbr;
235
- const next: SplitNode = { ...parent, sizes };
236
- return { ...ws, root: replaceNode(ws.root, parent, next) };
37
+ /** The only layout: four equal-width read-only surfaces. */
38
+ export function defaultWorkspace(model: GraphModel): Workspace {
39
+ const panes = [
40
+ pane("explore", graphRoots(model)[0] ?? null),
41
+ pane("detail"),
42
+ pane("focus"),
43
+ pane("health"),
44
+ ];
45
+ return { name: "Explore", panes, activePaneId: panes[0]!.id };
237
46
  }
238
47
 
239
- // ---------------------------------------------------------------------------
240
- // Default workspaces (ship 3, decision 6: Explore is the first-run default)
241
- // ---------------------------------------------------------------------------
242
-
243
- /** The default first-run workspace: Explore 40/60. */
244
- export function defaultWorkspace(model: GraphModel, name = "Explore"): Workspace {
245
- const roots = graphRoots(model);
246
- const explore = paneNode("explore", roots[0] ?? null);
247
- const detail = paneNode("detail", null);
248
- const root = splitNode("row", [explore, detail], [40, 60]);
249
- return { name, root, activePaneId: explore.id };
250
- }
251
-
252
- /** Triple: Explore 30 / Detail 40 / Focus 30 — walk the graph while reading. */
253
- export function tripleWorkspace(name = "Triple"): Workspace {
254
- const explore = paneNode("explore");
255
- const detail = paneNode("detail");
256
- const focus = paneNode("focus");
257
- const root = splitNode("row", [explore, detail, focus], [30, 40, 30]);
258
- return { name, root, activePaneId: explore.id };
259
- }
260
-
261
- /** Wide: dashboard on top, reading below. */
262
- export function wideWorkspace(name = "Wide"): Workspace {
263
- const explore = paneNode("explore");
264
- const health = paneNode("health");
265
- const detail = paneNode("detail");
266
- const top = splitNode("row", [explore, health], [50, 50]);
267
- const root = splitNode("column", [top, detail], [40, 60]);
268
- return { name, root, activePaneId: explore.id };
269
- }
270
-
271
- /** All three named defaults. */
272
- export function defaultWorkspaces(model: GraphModel): Workspace[] {
273
- return [defaultWorkspace(model), tripleWorkspace(), wideWorkspace()];
274
- }
275
-
276
- // ---------------------------------------------------------------------------
277
- // Responsive collapse (design §9.2)
278
- // ---------------------------------------------------------------------------
279
-
280
- /**
281
- * Collapse the workspace to fit a terminal width:
282
- * - ≥ 110: unchanged.
283
- * - 80–109: at most one column split — collapse any column split nested inside
284
- * another split down to its first child pane.
285
- * - < 80: single visible pane (the active one); the component shows the others
286
- * as a one-line tab bar (decision 5).
287
- */
48
+ /** Hide inactive panes in a narrow terminal. */
288
49
  export function collapseForWidth(ws: Workspace, width: number): Workspace {
289
- if (width >= 110) return ws;
290
- if (width < 80) {
291
- const active = findPane(ws.root, ws.activePaneId);
292
- const single = active ? { ...active, nodeId: active.nodeId } : paneNode("explore");
293
- return { ...ws, root: single, activePaneId: single.id };
294
- }
295
- return { ...ws, root: collapseNestedColumns(ws.root) };
296
- }
297
-
298
- /** Flatten a column split nested directly under another column split to its first pane. */
299
- export function collapseNestedColumns(node: WorkspaceNode, insideColumn = false): WorkspaceNode {
300
- if (node.type === "pane") return node;
301
- const wasColumn = node.direction === "column";
302
- const children = node.children.map((c) => collapseNestedColumns(c, wasColumn));
303
- if (node.direction === "column" && insideColumn && children.length > 1) {
304
- return children[0] ?? node;
305
- }
306
- return { ...node, children };
307
- }
308
-
309
- // ---------------------------------------------------------------------------
310
- // Serialize / deserialize (persistence shape; the component adds the vault IO)
311
- // ---------------------------------------------------------------------------
312
-
313
- export interface SerializedWorkspace {
314
- name: string;
315
- activePaneId: string;
316
- root: SerializedNode;
317
- }
318
- export type SerializedNode = { t: "pane"; id: string; s: SurfaceKind; n: string | null } | { t: "split"; d: SplitDir; sizes: number[]; c: SerializedNode[] };
319
-
320
- export function serialize(ws: Workspace): SerializedWorkspace {
321
- return { name: ws.name, activePaneId: ws.activePaneId, root: serializeNode(ws.root) };
322
- }
323
-
324
- function serializeNode(node: WorkspaceNode): SerializedNode {
325
- if (node.type === "pane") return { t: "pane", id: node.id, s: node.surface, n: node.nodeId };
326
- return { t: "split", d: node.direction, sizes: node.sizes, c: node.children.map(serializeNode) };
327
- }
328
-
329
- /** Deserialize a serialized workspace; degrades to a fresh Explore on bad shape. */
330
- export function deserialize(json: SerializedWorkspace | null, fallback: Workspace): Workspace {
331
- if (!json || typeof json.name !== "string") return fallback;
332
- const root = deserializeNode(json.root);
333
- if (!root) return fallback;
334
- const active = findPane(root, json.activePaneId);
335
- return {
336
- name: json.name,
337
- root,
338
- activePaneId: active ? active.id : workspacePanes({ name: json.name, root, activePaneId: json.activePaneId })[0]?.id ?? newPaneId(),
339
- };
340
- }
341
-
342
- function deserializeNode(node: SerializedNode | undefined): WorkspaceNode | null {
343
- if (!node) return null;
344
- if (node.t === "pane") return { type: "pane", id: node.id || newPaneId(), surface: node.s, nodeId: node.n ?? null };
345
- if (node.t === "split") {
346
- const children = (node.c ?? []).map(deserializeNode).filter((c): c is WorkspaceNode => c !== null);
347
- if (children.length === 0) return null;
348
- return { type: "split", direction: node.d, sizes: node.sizes ?? children.map(() => 1), children };
349
- }
350
- return null;
351
- }
352
-
353
- // ---------------------------------------------------------------------------
354
- // Helpers
355
- // ---------------------------------------------------------------------------
356
-
357
- /** Find the split node directly containing pane `id`, or null if root. */
358
- function findParent(node: WorkspaceNode, id: string): SplitNode | null {
359
- if (node.type === "pane") return null;
360
- for (const c of node.children) {
361
- if (c.type === "pane" && c.id === id) return node;
362
- }
363
- for (const c of node.children) {
364
- const found = c.type === "split" ? findParent(c, id) : null;
365
- if (found) return found;
366
- }
367
- return null;
368
- }
369
-
370
- /** Replace `parent` (by reference identity within a node tree) with a new split. */
371
- function replaceChild(root: WorkspaceNode, parent: SplitNode, children: WorkspaceNode[]): WorkspaceNode {
372
- const next: SplitNode = { ...parent, children };
373
- return replaceNode(root, parent, next);
374
- }
375
-
376
- /** Swap one node reference for another within a tree (structural copy). */
377
- function replaceNode(root: WorkspaceNode, target: SplitNode, replacement: SplitNode): WorkspaceNode {
378
- if (root === target) return replacement;
379
- if (root.type === "pane") return root;
380
- return { ...root, children: root.children.map((c) => replaceNode(c, target, replacement)) };
50
+ if (width >= 80) return ws;
51
+ const active = findPane(ws.panes, ws.activePaneId);
52
+ return active ? { ...ws, panes: [active] } : ws;
381
53
  }