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,453 @@
1
+ /**
2
+ * The global keyboard map (weave-workspace §11 P4).
3
+ *
4
+ * > Global keys: `⌘K` search, `⌘1/2/3` focus column, `/` filter tree, `g` fit
5
+ * > graph, `?` help, `Esc` clear selection. Vim-ish `j/k` in the tree.
6
+ * > *Exit: the whole workspace is drivable without a mouse.*
7
+ *
8
+ * One pure function, {@link shellKey}, maps an **event descriptor** and a
9
+ * **context** to an action or to `null`. Nothing here listens, nothing here
10
+ * calls `preventDefault`, and nothing here knows what a `KeyboardEvent` is —
11
+ * {@link KeyDescriptor} is five booleans and a string. `keys.ts` does the
12
+ * subscribing and `Shell.tsx` does the dispatching, which is what makes the
13
+ * whole keymap coverable without a DOM (§10).
14
+ *
15
+ * ## `null` means "the browser keeps it", and it is the important case
16
+ *
17
+ * This is `treeKey`'s `handled: false` contract, promoted to the document.
18
+ * The stakes are higher here: `treeKey` sits on one column, while this
19
+ * handler sees **every** keystroke in the workspace. A global handler that
20
+ * swallows Tab traps keyboard users; one that swallows ⌘R breaks reload; one
21
+ * that fires `g` while a user is typing "graph" into the filter box makes
22
+ * text entry impossible. So the default is `null` and every claim is narrow:
23
+ *
24
+ * | | Claimed when |
25
+ * | --- | --- |
26
+ * | `⌘K` / `Ctrl K` | always — a modifier shortcut is unambiguous even mid-word |
27
+ * | `⌘1` `⌘2` `⌘3` | always |
28
+ * | `⌘E` `⌘S` | always — the editor's two keys (§11 P5.4) |
29
+ * | `Esc` | an overlay is open, or something is selected. Never otherwise |
30
+ * | `/` `g` `?` | **only** when focus is not in a text field and no overlay is open |
31
+ *
32
+ * `⌘S` is claimed **even while typing**, which is the one place this map
33
+ * deliberately overrides the browser. It has to be: the browser's `⌘S` is
34
+ * "save this page as HTML", which in a note editor is never what the user
35
+ * meant, and the moment they most want to save is the moment their cursor is
36
+ * in the textarea. `⌘E` is claimed on the same grounds — the browser has no
37
+ * default for it, and toggling out of the editor is a thing to do *from*
38
+ * inside the editor. Neither fires when an overlay is open, because the
39
+ * palette owns every key but `Escape`.
40
+ *
41
+ * `j` / `k` are not here at all: they are tree-scoped, so they live in
42
+ * `tree.model.ts`'s `normalizeTreeKey` where the tree's own handler applies
43
+ * them — a global `j` would move the tree selection while the user is looking
44
+ * at the graph.
45
+ *
46
+ * ## While an overlay is open, the global map is one key wide
47
+ *
48
+ * The palette contains a text input and its own listbox navigation, and the
49
+ * help sheet is a trap the user must be able to leave. Anything beyond
50
+ * `Escape` reaching the document from inside a modal is a bug — most sharply
51
+ * `⌘K`, which would otherwise re-open the palette on top of itself.
52
+ */
53
+
54
+ import type { ColumnId } from "./layout.model";
55
+ import type { OverlayId } from "./shell.model";
56
+
57
+ // --- the event, without the DOM ---------------------------------------------------
58
+
59
+ /**
60
+ * A keystroke, reduced to what a decision needs.
61
+ *
62
+ * `typing` rather than a target element: whether focus sits in a text field
63
+ * is the only property of the target this map cares about, and passing the
64
+ * element would drag `HTMLElement` into a module the root `tsconfig.json`
65
+ * project compiles.
66
+ */
67
+ export interface KeyDescriptor {
68
+ /** `KeyboardEvent.key` — `"k"`, `"Escape"`, `"/"`, `"?"`. */
69
+ readonly key: string;
70
+ readonly ctrl: boolean;
71
+ readonly meta: boolean;
72
+ readonly shift: boolean;
73
+ readonly alt: boolean;
74
+ /** Focus is in an `<input>`, `<textarea>`, `<select>` or contenteditable. */
75
+ readonly typing: boolean;
76
+ }
77
+
78
+ /** What the shell knows that changes what a key means. */
79
+ export interface KeyContext {
80
+ readonly overlay: OverlayId;
81
+ /** Whether §1.3's `selectedId` holds anything. Decides what `Esc` does. */
82
+ readonly hasSelection: boolean;
83
+ }
84
+
85
+ // --- actions ----------------------------------------------------------------------
86
+
87
+ /**
88
+ * Everything a global key can ask the shell to do.
89
+ *
90
+ * A discriminated union rather than callbacks, so the map is *data* the tests
91
+ * can assert equality against — P4's exit criterion is "every action
92
+ * reachable by keyboard has a tested mapping", and a table of callbacks is
93
+ * not something you can write that assertion about.
94
+ */
95
+ export type ShellAction =
96
+ | { readonly type: "openSearch" }
97
+ | { readonly type: "openHelp" }
98
+ | { readonly type: "closeOverlay" }
99
+ | { readonly type: "focusColumn"; readonly column: ColumnId }
100
+ /** Focus the tree's filter box — `/`. */
101
+ | { readonly type: "filterTree" }
102
+ | { readonly type: "fitGraph" }
103
+ | { readonly type: "clearSelection" }
104
+ /** `⌘E` — toggle the note column between read and edit (§11 P5.4). */
105
+ | { readonly type: "toggleEdit" }
106
+ /** `⌘S` — save the open draft. */
107
+ | { readonly type: "saveNote" };
108
+
109
+ /**
110
+ * The command-key letters, as data.
111
+ *
112
+ * A table for the same reason {@link COLUMN_DIGITS} is one: "every editor key
113
+ * has a mapping" becomes a test over this object rather than three
114
+ * hand-written cases, and the help sheet is generated from it so it cannot
115
+ * document a key the code does not implement.
116
+ */
117
+ export const COMMAND_KEYS: Readonly<Record<string, ShellAction>> = {
118
+ k: { type: "openSearch" },
119
+ e: { type: "toggleEdit" },
120
+ s: { type: "saveNote" },
121
+ };
122
+
123
+ /**
124
+ * `⌘1/2/3` → column, in §1.2's left-to-right order.
125
+ *
126
+ * A table rather than an `if` chain so the digits and the columns cannot
127
+ * drift apart, and so "there is a shortcut for every column" is a test over
128
+ * `COLUMNS` rather than three hand-written cases.
129
+ */
130
+ export const COLUMN_DIGITS: Readonly<Record<string, ColumnId>> = { "1": "tree", "2": "note", "3": "graph" };
131
+
132
+ /**
133
+ * Whether a modifier combination counts as "the platform's command key".
134
+ *
135
+ * Meta **or** Control, never both, and never with Alt or Shift. Accepting
136
+ * either is what makes one keymap work on both platforms without the model
137
+ * knowing which one it is on; rejecting the combinations keeps `⌥⌘K` and
138
+ * `⇧⌘K` — which browsers and extensions bind — out of our hands.
139
+ */
140
+ export function isCommand(event: KeyDescriptor): boolean {
141
+ return event.ctrl !== event.meta && !event.alt && !event.shift;
142
+ }
143
+
144
+ /**
145
+ * Whether a bare-letter shortcut may fire.
146
+ *
147
+ * No modifiers of any kind. Shift is deliberately *not* excluded: `?` is
148
+ * Shift+`/` on most layouts, and `KeyboardEvent.key` has already resolved
149
+ * that to `"?"`, so testing Shift here would make the help key unreachable on
150
+ * exactly the keyboards it was designed for.
151
+ */
152
+ export function isBare(event: KeyDescriptor): boolean {
153
+ return !event.ctrl && !event.meta && !event.alt;
154
+ }
155
+
156
+ /**
157
+ * The bare-letter shortcuts, as data.
158
+ *
159
+ * `j` and `k` are absent on purpose — see the module header.
160
+ */
161
+ export const BARE_KEYS: Readonly<Record<string, ShellAction>> = {
162
+ "/": { type: "filterTree" },
163
+ g: { type: "fitGraph" },
164
+ "?": { type: "openHelp" },
165
+ };
166
+
167
+ /**
168
+ * Map a keystroke to an action, or to `null` to leave it to the browser.
169
+ *
170
+ * Ordered deliberately: the overlay gate first (a modal narrows the whole map
171
+ * to one key), then the modifier shortcuts (which fire even mid-word), then
172
+ * `Escape`, then the bare letters (which do not).
173
+ */
174
+ export function shellKey(event: KeyDescriptor, ctx: KeyContext): ShellAction | null {
175
+ if (ctx.overlay !== null) return event.key === "Escape" ? { type: "closeOverlay" } : null;
176
+
177
+ if (isCommand(event)) {
178
+ // Lower-cased because ⌘K with caps lock on reports `"K"`, and a shortcut
179
+ // that stops working under caps lock is a bug report nobody can reproduce.
180
+ const letter = COMMAND_KEYS[event.key.toLowerCase()];
181
+ if (letter !== undefined) return letter;
182
+ const column = COLUMN_DIGITS[event.key];
183
+ return column === undefined ? null : { type: "focusColumn", column };
184
+ }
185
+
186
+ // Only claimed when there is a selection to clear. With nothing selected,
187
+ // `Esc` belongs to whatever else the browser or a future dialog wants it
188
+ // for, and swallowing it silently would be the kind of key theft this
189
+ // module exists to avoid.
190
+ if (event.key === "Escape") return ctx.hasSelection ? { type: "clearSelection" } : null;
191
+
192
+ if (!isBare(event) || event.typing) return null;
193
+ return BARE_KEYS[event.key] ?? null;
194
+ }
195
+
196
+ // --- the typing guard -------------------------------------------------------------
197
+
198
+ /**
199
+ * Element names that swallow a bare letter.
200
+ *
201
+ * `SELECT` is included, and it is the one people forget: a `<select>` does
202
+ * type-ahead, so `g` inside the graph column's depth control should jump to
203
+ * an option beginning with "g" rather than refit the graph. The workspace has
204
+ * exactly one such control today (`[depth 1 ▾]`) and that is enough.
205
+ */
206
+ export const TEXT_ENTRY_TAGS: readonly string[] = ["INPUT", "TEXTAREA", "SELECT"];
207
+
208
+ /**
209
+ * Whether focus is somewhere a bare letter must be left alone.
210
+ *
211
+ * Takes the tag name and the contenteditable flag rather than an element, so
212
+ * it is pure and so this module names no DOM type. `null` — no target at all,
213
+ * which a synthetic event can produce — is not typing.
214
+ */
215
+ export function isTextEntry(tagName: string | null, contentEditable: boolean): boolean {
216
+ if (contentEditable) return true;
217
+ return tagName !== null && TEXT_ENTRY_TAGS.includes(tagName.toUpperCase());
218
+ }
219
+
220
+ /**
221
+ * The slice of an event target this module reads.
222
+ *
223
+ * Both members optional, which is what lets the platform's `EventTarget` —
224
+ * which has neither — satisfy it structurally. A `window` or a `document`
225
+ * target therefore reads as "not typing", which is correct.
226
+ */
227
+ export interface KeyTarget {
228
+ readonly tagName?: string | undefined;
229
+ readonly isContentEditable?: boolean | undefined;
230
+ }
231
+
232
+ /**
233
+ * The slice of `KeyboardEvent` the subscription reads.
234
+ *
235
+ * Structural, so the platform's satisfies it without a cast and a fake is an
236
+ * object literal — the same port shape `EventSourceLike` and `HttpResponse`
237
+ * use, for the same reason: there is no DOM test environment (§10).
238
+ */
239
+ export interface KeyboardEventLike {
240
+ readonly key: string;
241
+ readonly ctrlKey: boolean;
242
+ readonly metaKey: boolean;
243
+ readonly shiftKey: boolean;
244
+ readonly altKey: boolean;
245
+ readonly target: KeyTarget | null;
246
+ preventDefault(): void;
247
+ }
248
+
249
+ /** Reduce a platform event to a {@link KeyDescriptor}. */
250
+ export function describeKey(event: KeyboardEventLike): KeyDescriptor {
251
+ return {
252
+ key: event.key,
253
+ ctrl: event.ctrlKey,
254
+ meta: event.metaKey,
255
+ shift: event.shiftKey,
256
+ alt: event.altKey,
257
+ typing: isTextEntry(event.target?.tagName ?? null, event.target?.isContentEditable === true),
258
+ };
259
+ }
260
+
261
+ // --- where an action points ---------------------------------------------------------
262
+
263
+ /**
264
+ * `⌘1/2/3` → the element to focus in that column.
265
+ *
266
+ * Selectors rather than refs, and the reason is the breakpoint: below 1100 px
267
+ * the graph column does not exist and below 800 px the tree does not either,
268
+ * so a ref would be `null` for a column that is legitimately absent and the
269
+ * shell would need a branch per column to cope. A `querySelector` that finds
270
+ * nothing is already the same answer, expressed once.
271
+ *
272
+ * Each target is the column's *content*, not its `<section>`: focusing the
273
+ * section would put the ring around the whole pane and leave the arrow keys
274
+ * pointing at nothing.
275
+ */
276
+ export const COLUMN_FOCUS_SELECTORS: Readonly<Record<ColumnId, string>> = {
277
+ tree: ".weave-col-tree .weave-rows",
278
+ note: ".weave-col-note .weave-note-body",
279
+ graph: ".weave-col-graph .weave-graph-canvas",
280
+ };
281
+
282
+ /** `/` → the tree's filter box. */
283
+ export const TREE_FILTER_SELECTOR = ".weave-col-tree .weave-filter";
284
+
285
+ /** The slice of an element {@link focusable} produces. */
286
+ export interface Focusable {
287
+ focus(): void;
288
+ }
289
+
290
+ /**
291
+ * Narrow an unknown `querySelector` result to something focusable.
292
+ *
293
+ * The port is typed `unknown` rather than `Element` for a compiler reason
294
+ * with an architectural upside. `Element` has no `focus()`, so a port
295
+ * declaring `querySelector(s): Focusable | null` is *not* satisfied by the
296
+ * real `document` in either direction and would need a cast at the one call
297
+ * site — which is exactly where a cast is least welcome. Taking `unknown` and
298
+ * checking here keeps `document` structurally valid, keeps this module free
299
+ * of DOM types, and turns "the element vanished at this breakpoint" into a
300
+ * tested branch rather than a `TypeError`.
301
+ */
302
+ export function focusable(value: unknown): Focusable | null {
303
+ const candidate = value as { focus?: unknown } | null | undefined;
304
+ return typeof candidate?.focus === "function" ? (candidate as Focusable) : null;
305
+ }
306
+
307
+ /** The slice of `document` {@link focusSelector} needs. */
308
+ export interface QueryHost {
309
+ querySelector(selector: string): unknown;
310
+ }
311
+
312
+ /**
313
+ * Focus the first match, and report whether there was one.
314
+ *
315
+ * A miss is **normal**, not an error: below 1100 px the graph column does not
316
+ * exist and below 800 px neither does the tree, so `⌘3` on a narrow window
317
+ * legitimately finds nothing. Returning `false` rather than throwing is what
318
+ * lets the caller stay a one-liner, and the boolean is what the test asserts.
319
+ */
320
+ export function focusSelector(host: QueryHost, selector: string): boolean {
321
+ const element = focusable(host.querySelector(selector));
322
+ if (element === null) return false;
323
+ element.focus();
324
+ return true;
325
+ }
326
+
327
+ // --- performing an action --------------------------------------------------------
328
+
329
+ /**
330
+ * The four capabilities an action needs, injected.
331
+ *
332
+ * Named capabilities rather than a component's `setState` closures, so
333
+ * {@link runShellAction} — which is a seven-armed switch, and therefore
334
+ * exactly the kind of thing §10 forbids putting in a `.tsx` — is testable
335
+ * with an object literal that records calls.
336
+ */
337
+ export interface ShellEffects {
338
+ setOverlay(overlay: OverlayId): void;
339
+ /** Focus the first element matching a selector. */
340
+ focusSelector(selector: string): boolean;
341
+ /** Frame the whole graph — the `[fit]` control's action. */
342
+ fitGraph(): void;
343
+ /** Write `null` to §1.3's `selectedId`. */
344
+ clearSelection(): void;
345
+ /** `⌘E` — dispatch `toggle` into the note editor. */
346
+ toggleEdit(): void;
347
+ /** `⌘S` — dispatch `save` into the note editor. */
348
+ saveNote(): void;
349
+ }
350
+
351
+ /** Perform an action. Total over {@link ShellAction}. */
352
+ export function runShellAction(action: ShellAction, fx: ShellEffects): void {
353
+ switch (action.type) {
354
+ case "openSearch":
355
+ return fx.setOverlay("search");
356
+ case "openHelp":
357
+ return fx.setOverlay("help");
358
+ case "closeOverlay":
359
+ return fx.setOverlay(null);
360
+ case "focusColumn":
361
+ // The boolean is deliberately dropped: a column that is not on screen
362
+ // at this breakpoint is a miss with nothing to report to.
363
+ return void fx.focusSelector(COLUMN_FOCUS_SELECTORS[action.column]);
364
+ case "filterTree":
365
+ return void fx.focusSelector(TREE_FILTER_SELECTOR);
366
+ case "fitGraph":
367
+ return fx.fitGraph();
368
+ case "clearSelection":
369
+ return fx.clearSelection();
370
+ case "toggleEdit":
371
+ return fx.toggleEdit();
372
+ case "saveNote":
373
+ return fx.saveNote();
374
+ }
375
+ }
376
+
377
+ // --- the help sheet -----------------------------------------------------------------
378
+
379
+ /** One row of the `?` overlay. */
380
+ export interface KeyHelpEntry {
381
+ /** Rendered combination, with `⌘` or `Ctrl` already resolved. */
382
+ readonly combo: string;
383
+ readonly what: string;
384
+ }
385
+
386
+ /** A titled group of rows. */
387
+ export interface KeyHelpGroup {
388
+ readonly title: string;
389
+ readonly entries: readonly KeyHelpEntry[];
390
+ }
391
+
392
+ /**
393
+ * The help sheet's contents.
394
+ *
395
+ * Derived from the same constants the map is built from wherever it can be —
396
+ * `COLUMN_DIGITS` drives the three column rows — because a help overlay that
397
+ * documents a key the code does not implement is worse than no help overlay.
398
+ * The rows that cannot be derived (the arrows, which belong to `treeKey` and
399
+ * `searchKey`) are asserted against those modules by the test suite instead.
400
+ *
401
+ * @param cmd the platform's command-key spelling, from `searchShortcut`'s
402
+ * `looksApple` — so the sheet says `⌘K` on a Mac and `Ctrl K` elsewhere,
403
+ * rather than teaching half its readers a key they do not have.
404
+ */
405
+ export function keyHelp(cmd: string): readonly KeyHelpGroup[] {
406
+ return [
407
+ {
408
+ title: "Global",
409
+ entries: [
410
+ { combo: `${cmd}K`, what: "Search notes and the repository" },
411
+ ...Object.entries(COLUMN_DIGITS).map(([digit, column]) => ({ combo: `${cmd}${digit}`, what: `Focus the ${column} column` })),
412
+ { combo: "?", what: "This help" },
413
+ { combo: "Esc", what: "Clear the selection, or close an overlay" },
414
+ ],
415
+ },
416
+ {
417
+ title: "Tree",
418
+ entries: [
419
+ { combo: "/", what: "Filter the tree" },
420
+ { combo: "j / ↓", what: "Next row" },
421
+ { combo: "k / ↑", what: "Previous row" },
422
+ { combo: "→", what: "Expand, or step into" },
423
+ { combo: "←", what: "Collapse, or step out" },
424
+ { combo: "Home / End", what: "First / last row" },
425
+ ],
426
+ },
427
+ {
428
+ title: "Note",
429
+ entries: [
430
+ { combo: `${cmd}E`, what: "Toggle read / edit" },
431
+ { combo: `${cmd}S`, what: "Save the open draft" },
432
+ ],
433
+ },
434
+ {
435
+ title: "Graph",
436
+ entries: [{ combo: "g", what: "Fit the whole graph" }],
437
+ },
438
+ {
439
+ title: "Search",
440
+ entries: [
441
+ { combo: "↑ / ↓", what: "Move through results" },
442
+ { combo: "↵", what: "Open the highlighted result" },
443
+ { combo: "Esc", what: "Close the palette" },
444
+ ],
445
+ },
446
+ ];
447
+ }
448
+
449
+ /** The help dialog's accessible name and heading. */
450
+ export const HELP_TITLE = "Keyboard shortcuts";
451
+
452
+ /** The help dialog's footer hint. */
453
+ export const HELP_HINT = "esc close";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The global key subscription (weave-workspace §11 P4, §10).
3
+ *
4
+ * `keys.model.ts` decides what a keystroke means; this attaches one listener
5
+ * and performs the answer. Four lines of it are the listener and the rest is
6
+ * dispatch, which is deliberate — the same split `viewport.ts` uses, and for
7
+ * the same reason: a subscription written inline in a `useEffect` is one no
8
+ * test can reach.
9
+ *
10
+ * ## One listener, on the document, at the bubble phase
11
+ *
12
+ * Not capture. The bubble phase runs *after* the element under focus has had
13
+ * the event, so the tree's own `onKeyDown` claims `ArrowDown` before this
14
+ * ever sees it and the palette claims `Enter` before this does. That ordering
15
+ * is what lets both handlers stay simple: neither needs to know the other
16
+ * exists, because the local one goes first and the global one is the
17
+ * fallback. Capture would invert it and every column-level key would need an
18
+ * exception here.
19
+ *
20
+ * `preventDefault` is called **only** for a keystroke that produced an
21
+ * action. Everything else — Tab, ⌘R, ⌘L, typing — passes through untouched,
22
+ * which is `treeKey`'s `handled: false` contract at the document level.
23
+ */
24
+
25
+ import type { KeyContext, KeyboardEventLike, ShellAction } from "./keys.model";
26
+ import { describeKey, shellKey } from "./keys.model";
27
+
28
+ /** The slice of `document` this module subscribes to. */
29
+ export interface KeyHost {
30
+ addEventListener(type: "keydown", listener: (event: KeyboardEventLike) => void): void;
31
+ removeEventListener(type: "keydown", listener: (event: KeyboardEventLike) => void): void;
32
+ }
33
+
34
+ /** What {@link watchKeys} needs. */
35
+ export interface KeyOptions {
36
+ /** Read fresh per keystroke — the overlay and the selection both move. */
37
+ context: () => KeyContext;
38
+ run: (action: ShellAction) => void;
39
+ }
40
+
41
+ /**
42
+ * Listen for global shortcuts. Returns an unsubscribe.
43
+ *
44
+ * The context is a **thunk**, not a value. A listener registered at mount
45
+ * outlives every render, so a captured `{overlay, hasSelection}` would answer
46
+ * with the state as it was at mount — the palette would never see itself as
47
+ * open, and `⌘K` would stack overlays. The same `live.current` reasoning
48
+ * `Shell.tsx` and `Graph.tsx` apply to their long-lived handlers.
49
+ */
50
+ export function watchKeys(host: KeyHost, opts: KeyOptions): () => void {
51
+ const listener = (event: KeyboardEventLike): void => {
52
+ const action = shellKey(describeKey(event), opts.context());
53
+ if (action === null) return;
54
+ event.preventDefault();
55
+ opts.run(action);
56
+ };
57
+ host.addEventListener("keydown", listener);
58
+ return () => host.removeEventListener("keydown", listener);
59
+ }