pi-weave 0.1.7 → 0.1.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 (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Everything the shell *decides*, as pure functions (weave-workspace §1.2).
3
+ *
4
+ * The header's status summary, the connection indicator's wording, the
5
+ * per-column empty-state copy and the status bar's text all live here rather
6
+ * than inside the components that render them. That is not a stylistic
7
+ * preference: §10 forbids adding a DOM test environment, so a conditional
8
+ * inside a `.tsx` is a conditional that can never be covered, and §14 lists
9
+ * "coverage gate blocks the UI work" as a live risk whose stated mitigation
10
+ * is exactly this split. Each `.tsx` under `shell/` is therefore props-in,
11
+ * JSX-out, with every branch it might have wanted resolved here first.
12
+ *
13
+ * Compiled by the root `tsconfig.json` when a test imports it, so: no DOM
14
+ * types, no `node:*`, no `src/core`.
15
+ */
16
+
17
+ import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
18
+ import type { ConnectionState } from "../state";
19
+ import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
20
+ import { DIVIDERS } from "./layout.model";
21
+
22
+ // --- the header summary --------------------------------------------------------
23
+
24
+ /**
25
+ * The `vault:34 · repo:fresh · 127 nodes` readout from the §1.2 sketch.
26
+ *
27
+ * Kept as three fields rather than one pre-joined string so the component can
28
+ * put a separator between them without this module owning a `·`, and so a
29
+ * test asserts the numbers rather than a formatting decision.
30
+ */
31
+ export interface HeaderSummary {
32
+ /** Note nodes in the vault. */
33
+ readonly notes: number;
34
+ /** Repository index freshness, or `null` when the repo is unindexed. */
35
+ readonly repo: WireStalenessState | null;
36
+ /** Every node, of every kind. */
37
+ readonly nodes: number;
38
+ }
39
+
40
+ /** The summary before the first graph arrives. */
41
+ export const EMPTY_SUMMARY: HeaderSummary = { notes: 0, repo: null, nodes: 0 };
42
+
43
+ /**
44
+ * Which node kinds count as "vault" for the header.
45
+ *
46
+ * Only `note`. The `vault` node itself is the container, and counting it
47
+ * would make an empty vault read `vault:1` — a number that is technically
48
+ * defensible and would still be read as "there is one note in there".
49
+ */
50
+ const VAULT_KINDS: readonly WireNodeKind[] = ["note"];
51
+
52
+ /** Derive the header counts from a payload. */
53
+ export function summarize(payload: GraphPayload | null): HeaderSummary {
54
+ if (payload === null) return EMPTY_SUMMARY;
55
+ const nodes = payload.model.nodes;
56
+ return {
57
+ notes: nodes.filter((node) => VAULT_KINDS.includes(node.kind)).length,
58
+ repo: payload.model.staleness?.state ?? null,
59
+ nodes: nodes.length,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The repo segment's text.
65
+ *
66
+ * `"unindexed"` rather than `"missing"` for a null or `missing` staleness:
67
+ * "missing" reads as an error, and a repository that has simply never been
68
+ * scanned is the ordinary first-run state, not a fault. The word the user
69
+ * needs is the one that implies an action they can take.
70
+ */
71
+ export function repoLabel(repo: WireStalenessState | null): string {
72
+ return repo === null || repo === "missing" ? "unindexed" : repo;
73
+ }
74
+
75
+ /** `vault:34 · repo:fresh · 127 nodes`, as the pieces to join. */
76
+ export function summaryParts(summary: HeaderSummary): readonly string[] {
77
+ return [`vault:${summary.notes}`, `repo:${repoLabel(summary.repo)}`, `${summary.nodes} nodes`];
78
+ }
79
+
80
+ // --- the connection indicator ----------------------------------------------------
81
+
82
+ /**
83
+ * How a connection state is presented.
84
+ *
85
+ * `tone` is a class suffix rather than a colour, so the palette stays in the
86
+ * stylesheet where §5.2's nonce'd CSS can own it and this module stays free
87
+ * of presentation constants it cannot test the appearance of.
88
+ */
89
+ export interface ConnectionView {
90
+ readonly label: string;
91
+ readonly tone: "ok" | "warn" | "bad";
92
+ /** The `title=` tooltip. Says what the user should expect to happen next. */
93
+ readonly hint: string;
94
+ }
95
+
96
+ const CONNECTION_VIEWS: Readonly<Record<ConnectionState, ConnectionView>> = {
97
+ live: { label: "live", tone: "ok", hint: "watching the vault and the repository for changes" },
98
+ reconnecting: {
99
+ label: "reconnecting",
100
+ tone: "warn",
101
+ // Naming the recovery matters: §6 has the client refetch everything on
102
+ // reopen, so the user's screen will catch up on its own and they should
103
+ // not go looking for a reload button.
104
+ hint: "the event stream dropped — retrying, and everything refetches when it returns",
105
+ },
106
+ offline: { label: "offline", tone: "bad", hint: "the workspace server is gone — use ⟳ to retry" },
107
+ };
108
+
109
+ /** Present a connection state. Total over the three states of §1.3. */
110
+ export function connectionView(state: ConnectionState): ConnectionView {
111
+ return CONNECTION_VIEWS[state];
112
+ }
113
+
114
+ // --- empty states ------------------------------------------------------------------
115
+
116
+ /**
117
+ * A column's title, and the placeholder shown while its content is someone
118
+ * else's phase.
119
+ *
120
+ * P1's deliverable for the three columns was an *honest* empty state naming
121
+ * the phase that would fill it — more useful than a spinner implying a load,
122
+ * and far more useful than a blank pane that reads as a bug.
123
+ *
124
+ * **As of P3 no column uses one.** All three are built, so `Columns.tsx`
125
+ * renders their real surfaces and each has its *own* empty states —
126
+ * `treeEmptyMessage`, `noteEmptyMessage` and `graphEmptyMessage`, which
127
+ * distinguish "loading" from "filtered to nothing" from "genuinely empty" in a
128
+ * way one static sentence cannot.
129
+ *
130
+ * The table stays because {@link EmptyStateCopy.title} is still every column's
131
+ * heading and its `aria-label`. {@link EmptyStateCopy.body} and `phase` are
132
+ * now dead for all three, and are kept accurate rather than deleted for one
133
+ * reason: `tests/web/client-shell.test.ts` asserts the `phase` values against
134
+ * §11, so a column whose phase silently disagreed with the doc would be the
135
+ * first sign that this table had stopped tracking reality. `EmptyState.tsx`
136
+ * itself now has no caller, and is deleted rather than kept warm — an unused
137
+ * component is a thing the next reader has to work out is unused.
138
+ */
139
+ export interface EmptyStateCopy {
140
+ readonly title: string;
141
+ readonly body: string;
142
+ /** The phase that fills this column in, e.g. `"P2"`. */
143
+ readonly phase: string;
144
+ }
145
+
146
+ const EMPTY_STATES: Readonly<Record<ColumnId, EmptyStateCopy>> = {
147
+ tree: {
148
+ title: "Tree",
149
+ // Built in P2; body and phase are no longer rendered. See the header.
150
+ body: "The vault and repository outline, expandable, filterable, with provenance markers.",
151
+ phase: "P2",
152
+ },
153
+ note: {
154
+ title: "Note",
155
+ body: "Select anything to read it here — rendered Markdown, front matter, tags and wikilinks.",
156
+ phase: "P2",
157
+ },
158
+ graph: {
159
+ title: "Graph",
160
+ // Built in P3; body and phase are no longer rendered. See the header.
161
+ body: "The knowledge graph, laid out and navigable, with the selection's neighbourhood highlighted.",
162
+ phase: "P3",
163
+ },
164
+ };
165
+
166
+ /** The empty-state copy for a column. */
167
+ export function emptyStateFor(column: ColumnId): EmptyStateCopy {
168
+ return EMPTY_STATES[column];
169
+ }
170
+
171
+ /**
172
+ * The context rail's title.
173
+ *
174
+ * Built in P2.5, so `body`/`phase` are no longer rendered either — the rail's
175
+ * real empty states are `RAIL_EMPTY` in `context/context.model.ts`, which
176
+ * separates "loading" from "nothing selected" from "this node is isolated".
177
+ * `title` is still the rail's heading and `aria-label`.
178
+ */
179
+ export const CONTEXT_EMPTY: EmptyStateCopy = {
180
+ title: "Context",
181
+ body: "Links, backlinks and mentions for whatever is selected.",
182
+ phase: "P2",
183
+ };
184
+
185
+ // --- the status bar -------------------------------------------------------------------
186
+
187
+ /**
188
+ * The status bar's segments, left to right.
189
+ *
190
+ * The selection is included because it is the one piece of state that is
191
+ * otherwise invisible at P1: with all three columns showing empty states,
192
+ * clicking something would have no observable effect at all, and a shell
193
+ * whose context bus cannot be seen working is a shell nobody can tell is
194
+ * wired up.
195
+ */
196
+ export interface StatusBarModel {
197
+ readonly cwd: string;
198
+ readonly selection: string;
199
+ readonly connection: ConnectionView;
200
+ /** `null` before the first successful graph fetch. */
201
+ readonly stamp: string | null;
202
+ }
203
+
204
+ /** The `—` shown where a value is genuinely absent, not zero. */
205
+ export const NO_VALUE = "—";
206
+
207
+ /** Build the status bar's model. */
208
+ export function statusBarModel(
209
+ cwd: string,
210
+ selectedId: string | null,
211
+ connection: ConnectionState,
212
+ stamp: string | null,
213
+ ): StatusBarModel {
214
+ return {
215
+ cwd: cwd === "" ? NO_VALUE : cwd,
216
+ selection: selectedId ?? "nothing selected",
217
+ connection: connectionView(connection),
218
+ stamp,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * A `generatedAt` stamp, shortened for the status bar.
224
+ *
225
+ * Just the `HH:MM:SS`, because the date is almost always today and the whole
226
+ * ISO string is 24 characters of mostly-constant noise in a bar that has
227
+ * three other things to say. A stamp that does not look like an ISO timestamp
228
+ * is passed through untouched rather than sliced blindly — the server derives
229
+ * it from input timestamps and is free to change that derivation, and a
230
+ * mangled substring would be a worse lie than the full value.
231
+ */
232
+ export function shortStamp(stamp: string | null): string {
233
+ if (stamp === null) return NO_VALUE;
234
+ const match = /T(\d{2}:\d{2}:\d{2})/.exec(stamp);
235
+ return match?.[1] ?? stamp;
236
+ }
237
+
238
+ // --- pairing columns with dividers ---------------------------------------------------
239
+
240
+ /**
241
+ * One rendered column, plus the divider that follows it (if any).
242
+ *
243
+ * `divider` is `null` for the last column: there are three columns and two
244
+ * dividers, and the trailing edge of the grid is the window, not a handle.
245
+ */
246
+ export interface ColumnSlot {
247
+ readonly column: ResolvedColumn;
248
+ readonly divider: DividerId | null;
249
+ }
250
+
251
+ /**
252
+ * Interleave resolved columns with the dividers between them.
253
+ *
254
+ * This exists as a model function rather than an `index + 1 < length` check
255
+ * inside the JSX for two reasons. The first is coverage: that check is a
256
+ * branch, and a branch in a `.tsx` is a branch no test can reach (§10). The
257
+ * second is that the naive version is *wrong at a breakpoint* — at `"medium"`
258
+ * only tree and note render, so the divider after `note` must not appear even
259
+ * though `DIVIDERS` contains one. Deriving the pairing from the columns
260
+ * actually being rendered, rather than from the static divider list, makes
261
+ * that impossible to get wrong.
262
+ */
263
+ export function columnSlots(resolved: readonly ResolvedColumn[]): readonly ColumnSlot[] {
264
+ return resolved.map((column, index) => ({
265
+ column,
266
+ // A divider is *named by the column to its left* (`layout.model.ts`'s
267
+ // `dividerPair`), so the id is the column's own — no index arithmetic into
268
+ // `DIVIDERS`, which would need an unreachable `?? null` to satisfy
269
+ // `noUncheckedIndexedAccess` and would leave a branch no test can cover.
270
+ // It exists only where a next column follows to resize against.
271
+ divider: index < resolved.length - 1 && isDivider(column.id) ? column.id : null,
272
+ }));
273
+ }
274
+
275
+ /** Whether a column has a divider named after it — i.e. is not the last one. */
276
+ function isDivider(column: ColumnId): column is DividerId {
277
+ return (DIVIDERS as readonly ColumnId[]).includes(column);
278
+ }
279
+
280
+ // --- the search affordance ---------------------------------------------------------
281
+
282
+ /**
283
+ * The `⌘K` control.
284
+ *
285
+ * Rendered **disabled** through P1–P3, with a title saying search arrived in
286
+ * P4 — a deliberate choice over omitting it: the keyboard hint taught the
287
+ * shortcut that would exist, and a `disabled` control explaining itself is
288
+ * honest in a way that a working-looking box that does nothing is not.
289
+ *
290
+ * P4 made it live, and it is now a *button* rather than an `<input>`. The
291
+ * palette owns the only text field, so a second one in the header would be
292
+ * two places to type a query into and one of them would be a lie — clicking
293
+ * it opens the overlay and whatever was typed into the header box would be
294
+ * discarded. A button that looks like a search field and opens the real one
295
+ * is the affordance the §1.2 sketch actually describes.
296
+ */
297
+ export const SEARCH_PLACEHOLDER = "Search…";
298
+
299
+ /** The shortcut hint. `⌘K` on Apple platforms, `Ctrl K` elsewhere. */
300
+ export function searchShortcut(isApple: boolean): string {
301
+ return isApple ? "⌘K" : "Ctrl K";
302
+ }
303
+
304
+ /** The `title=` on the live search button. Names the key that also opens it. */
305
+ export function searchHint(shortcut: string): string {
306
+ return `Search notes and repository (${shortcut})`;
307
+ }
308
+
309
+ // --- overlays ------------------------------------------------------------------
310
+
311
+ /**
312
+ * Which modal surface is open, if any.
313
+ *
314
+ * A single nullable id rather than one boolean per overlay, because the two
315
+ * are **mutually exclusive** and two booleans can represent a state that is
316
+ * not — help and search both open, stacked, each trapping focus against the
317
+ * other. Making that unrepresentable costs nothing here and removes a whole
318
+ * class of bug from the keyboard layer, which is the thing that opens them.
319
+ */
320
+ export type OverlayId = "search" | "help" | null;
321
+
322
+ /**
323
+ * Whether a platform string looks like an Apple one.
324
+ *
325
+ * Takes the string rather than reading `navigator`, so it is testable and so
326
+ * the single DOM read happens at the one call site in the shell. Substring
327
+ * matching on `Mac`, `iPhone` and `iPad` covers what `navigator.platform` and
328
+ * `userAgent` produce; anything unrecognised gets the `Ctrl` spelling, which
329
+ * is the right default because non-Apple is the larger population.
330
+ */
331
+ export function looksApple(platform: string): boolean {
332
+ return /mac|iphone|ipad/i.test(platform);
333
+ }