pi-weave 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Link and tag derivations over vault/graph data (weave-workspace §3, §4.3).
3
+ */
4
+
5
+ import type { GraphEdge } from "../graph/model";
6
+ import type { NoteMeta } from "../types";
7
+
8
+ /** Map each node id → its incoming `links-to` sources (backlinks). */
9
+ export function deriveBacklinks(edges: readonly GraphEdge[]): Map<string, string[]> {
10
+ const out = new Map<string, string[]>();
11
+ for (const e of edges) {
12
+ if (e.kind !== "links-to") continue;
13
+ const list = out.get(e.target);
14
+ if (list) list.push(e.source);
15
+ else out.set(e.target, [e.source]);
16
+ }
17
+ return out;
18
+ }
19
+
20
+ /**
21
+ * One tag and every note carrying it (weave-workspace §4.3).
22
+ *
23
+ * `slugs` rather than node ids: `note:` is a graph-internal prefix, and every
24
+ * consumer of this index — the note column's tag chips, `GraphPayload.tags` —
25
+ * addresses notes by slug.
26
+ */
27
+ export interface TagIndex {
28
+ tag: string;
29
+ slugs: string[];
30
+ }
31
+
32
+ /**
33
+ * The minimum a note has to expose to be tag-indexed: its identity and its
34
+ * tags.
35
+ *
36
+ * Declared structurally instead of taking `NoteSummary` outright so the
37
+ * function accepts a `Note` (which has a `body` and no `bodyLength`) just as
38
+ * happily as a `NoteSummary`. §4.3 specifies `NoteSummary[]`; this is that
39
+ * signature widened to its actual requirement rather than narrowed past it,
40
+ * and `NoteSummary` still satisfies it exactly.
41
+ */
42
+ export type TaggedNote = Pick<NoteMeta, "tags"> & { slug: string };
43
+
44
+ /**
45
+ * Invert `note → tags` into `tag → notes` (weave-workspace §4.3).
46
+ *
47
+ * ## Why this takes notes and not a `GraphModel`
48
+ *
49
+ * The obvious alternative is deriving from the graph, since every other
50
+ * function in this directory does. It is the wrong source here, and the
51
+ * reason is worth stating because "derive everything from the graph" is
52
+ * otherwise a good rule.
53
+ *
54
+ * `buildGraph` flattens tags into `detail.tags`, a comma-joined **display**
55
+ * string (`build.ts`: `note.tags.join(", ")`). Recovering an array from it
56
+ * means splitting on `", "` — which is re-parsing a display string into
57
+ * structure, precisely what §4.2/§4.3 forbid `detail` from being used for,
58
+ * and it is lossy besides: a tag containing a comma round-trips wrong, and
59
+ * the tag is simply absent when a note has none. The alternative is to make
60
+ * `GraphNode` carry a real `tags: string[]` alongside the display string,
61
+ * which puts the same fact on the node twice and grows the wire model for
62
+ * every note whether or not anything reads it.
63
+ *
64
+ * Notes are the upstream source of truth. Both callers already hold them —
65
+ * `buildCurrentGraph` and `WorkspaceCache` each read the vault before they
66
+ * build a graph — so deriving here costs no extra I/O and keeps one
67
+ * representation of a tag: the array on the note. The graph's display string
68
+ * stays a projection of that, never an input to it.
69
+ *
70
+ * ## Ordering
71
+ *
72
+ * Count descending, then tag ascending, per §4.3 — the popular tags surface
73
+ * first and ties are alphabetical rather than insertion-ordered. Each `slugs`
74
+ * list is sorted ascending too, so the whole structure is a pure function of
75
+ * the tag/slug *set*: two vaults with the same tags in a different note order
76
+ * produce byte-identical JSON, which is what lets this ride the ETag.
77
+ *
78
+ * The tiebreak is **codepoint** order, not `localeCompare`, which is the one
79
+ * place this file departs from the habit elsewhere in core. `localeCompare`
80
+ * reads the host's default locale and the runtime's ICU build — a Node
81
+ * compiled with small-icu can order `"Arch"` against `"arch"` differently
82
+ * from one with full-icu. Everywhere else that is a cosmetic difference in a
83
+ * list a human is about to read; here the output is hashed into a cache key
84
+ * (§5.3), so an ordering that varies by machine is a correctness bug rather
85
+ * than a preference. Codepoint order is total, locale-free and matches the
86
+ * bare `.sort()` used for `slugs` just below.
87
+ *
88
+ * Duplicate tags within one note collapse (front matter is hand-editable and
89
+ * `tags: [a, a]` is a typo, not two memberships).
90
+ */
91
+ export function deriveTagIndex(notes: readonly TaggedNote[]): TagIndex[] {
92
+ const bySlug = new Map<string, Set<string>>();
93
+ for (const note of notes) {
94
+ for (const tag of note.tags) {
95
+ const slugs = bySlug.get(tag);
96
+ if (slugs) slugs.add(note.slug);
97
+ else bySlug.set(tag, new Set([note.slug]));
98
+ }
99
+ }
100
+ return [...bySlug]
101
+ .map(([tag, slugs]): TagIndex => ({ tag, slugs: [...slugs].sort() }))
102
+ // Tags come from a Map's keys, so no two are equal and the comparator
103
+ // never needs to return 0 for the tiebreak — `< ? -1 : 1` is total here.
104
+ .sort((a, b) => b.slugs.length - a.slugs.length || (a.tag < b.tag ? -1 : 1));
105
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Time formatting for the portable view-models (weave-workspace §3).
3
+ */
4
+
5
+ import type { TreeMeta } from "./types";
6
+
7
+ /** Relative human time, mirroring the page's `relTime`. `now` is epoch ms. */
8
+ export function relTime(iso: string | undefined, now: number): string {
9
+ if (!iso) return "";
10
+ const t = Date.parse(iso);
11
+ if (Number.isNaN(t)) return "";
12
+ const s = Math.max(0, Math.floor((now - t) / 1000));
13
+ if (s < 60) return "just now";
14
+ const m = Math.floor(s / 60);
15
+ if (m < 60) return `${m}m ago`;
16
+ const h = Math.floor(m / 60);
17
+ if (h < 24) return `${h}h ago`;
18
+ const d = Math.floor(h / 24);
19
+ if (d < 30) return `${d}d ago`;
20
+ const mo = Math.floor(d / 30);
21
+ if (mo < 12) return `${mo}mo ago`;
22
+ return `${Math.floor(mo / 12)}y ago`;
23
+ }
24
+
25
+ /** Short-sha length used when abbreviating a commit for display. */
26
+ const SHORT_SHA_LEN = 7;
27
+
28
+ /**
29
+ * Render a `TreeMeta` as a single plain-text annotation.
30
+ *
31
+ * This reproduces the strings the TUI printed when `TreeRow.meta` was itself a
32
+ * string, so terminal output is unchanged by the structuring. Richer renderers
33
+ * (the browser workspace) should switch on the union instead of calling this.
34
+ */
35
+ export function formatTreeMeta(meta: TreeMeta, now: number): string {
36
+ if (meta === null) return "";
37
+ switch (meta.kind) {
38
+ case "relTime":
39
+ return relTime(meta.iso, now);
40
+ case "count":
41
+ return meta.phrasing === "attribute" ? `${meta.unit}=${meta.n}` : `${meta.n} ${meta.unit}`;
42
+ case "commit":
43
+ return meta.sha.slice(0, SHORT_SHA_LEN);
44
+ case "text":
45
+ return meta.text;
46
+ }
47
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * treeRows — the portable Explore/tree view-model (weave-workspace §3).
3
+ *
4
+ * Ported 1:1 from the retired browser page's `listTree`. Pure: it takes a
5
+ * `GraphModel` plus a `TreeState` and returns rows. No renderer concepts —
6
+ * no widths, no truncation, no escape codes. Both the TUI and the browser
7
+ * workspace build their tree column from this.
8
+ */
9
+
10
+ import type { GraphModel, GraphNode, NodeKind } from "../graph/model";
11
+ import type { NoteSource } from "../types";
12
+ import type { SelectableRow, TreeMeta } from "./types";
13
+
14
+ /** One row in the Explore tree. */
15
+ export interface TreeRow extends SelectableRow {
16
+ depth: number;
17
+ hasKids: boolean;
18
+ expanded: boolean;
19
+ label: string;
20
+ kind: NodeKind;
21
+ provenance: NoteSource | null;
22
+ /** Structured trailing annotation; renderers format it (see formatTreeMeta). */
23
+ meta: TreeMeta;
24
+ }
25
+
26
+ export interface TreeState {
27
+ /** Expanded node ids (when no filter is active). */
28
+ expanded: ReadonlySet<string>;
29
+ /** Reveal repo plumbing (gitState/external/package/entryPoint). */
30
+ showInternals: boolean;
31
+ /** Provenance filter (null = all). */
32
+ provFilter: NoteSource | null;
33
+ /** Substring filter on node labels (case-insensitive). */
34
+ query: string;
35
+ }
36
+
37
+ /** Disambiguate labels that would otherwise read as the same entry twice
38
+ * (a remote URL whose tail matches the repo name, an npm package named
39
+ * after the repo). Mirrors the page's `listLabel`. */
40
+ export function listLabel(node: GraphNode): string {
41
+ if (node.kind === "external" && node.detail.url) {
42
+ const u = node.detail.url
43
+ .replace(/^[a-z]+:\/\//, "")
44
+ .replace(/^[^@\/]+@/, "")
45
+ .replace(/\.git$/, "")
46
+ .replace(/\/+$/, "");
47
+ if (u) return u;
48
+ }
49
+ if (node.kind === "package" && node.detail.manifest) {
50
+ return `${node.label} (${node.detail.manifest})`;
51
+ }
52
+ return node.label;
53
+ }
54
+
55
+ /** Build the contains/anchored-at nesting + entry-point-to-module map. */
56
+ interface TreeIndex {
57
+ byId: Map<string, GraphNode>;
58
+ /** strict contains (used for entry-point → module placement). */
59
+ contains: Map<string, string[]>;
60
+ /** contains + anchored-at (the nesting hierarchy). */
61
+ tree: Map<string, string[]>;
62
+ /** node id → true when it has an incoming contains/anchored-at edge. */
63
+ incoming: Set<string>;
64
+ /** module id → entry-point ids nested under it (by path prefix). */
65
+ moduleEntries: Map<string, string[]>;
66
+ /** roots (nodes with no incoming edge), in node-input order. */
67
+ roots: string[];
68
+ }
69
+
70
+ function indexTree(model: GraphModel): TreeIndex {
71
+ const byId = new Map<string, GraphNode>();
72
+ for (const n of model.nodes) byId.set(n.id, n);
73
+
74
+ const contains = new Map<string, string[]>();
75
+ const tree = new Map<string, string[]>();
76
+ const incoming = new Set<string>();
77
+ for (const e of model.edges) {
78
+ if (e.kind === "contains") {
79
+ const list = contains.get(e.source);
80
+ if (list) list.push(e.target);
81
+ else contains.set(e.source, [e.target]);
82
+ }
83
+ if (e.kind === "contains" || e.kind === "anchored-at") {
84
+ const list = tree.get(e.source);
85
+ if (list) list.push(e.target);
86
+ else tree.set(e.source, [e.target]);
87
+ incoming.add(e.target);
88
+ }
89
+ }
90
+
91
+ // Entry points nest under the module whose path is the entry's directory prefix.
92
+ // The caller only invokes this for entryPoint nodes, so the path guard suffices.
93
+ const moduleFor = (entry: GraphNode): string | null => {
94
+ if (!entry.detail.path) return null;
95
+ const p = entry.detail.path;
96
+ let best: string | null = null;
97
+ let bestLen = 0;
98
+ const repoKids = contains.get("repository") ?? [];
99
+ for (const cid of repoKids) {
100
+ const c = byId.get(cid);
101
+ if (c && c.kind === "module" && c.detail.path) {
102
+ const mp = c.detail.path;
103
+ if (p.startsWith(mp + "/") && mp.length > bestLen) {
104
+ best = cid;
105
+ bestLen = mp.length;
106
+ }
107
+ }
108
+ }
109
+ return best;
110
+ };
111
+
112
+ const moduleEntries = new Map<string, string[]>();
113
+ for (const n of model.nodes) {
114
+ if (n.kind !== "entryPoint") continue;
115
+ const m = moduleFor(n);
116
+ if (m) {
117
+ const list = moduleEntries.get(m);
118
+ if (list) list.push(n.id);
119
+ else moduleEntries.set(m, [n.id]);
120
+ }
121
+ }
122
+
123
+ const roots = model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
124
+ return { byId, contains, tree, incoming, moduleEntries, roots };
125
+ }
126
+
127
+ /** The roots of a graph (nodes with no incoming contains/anchored-at edge). */
128
+ export function graphRoots(model: GraphModel): string[] {
129
+ const incoming = new Set<string>();
130
+ for (const e of model.edges) {
131
+ if (e.kind === "contains" || e.kind === "anchored-at") incoming.add(e.target);
132
+ }
133
+ return model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
134
+ }
135
+
136
+ /** Knowledge-first default: hide repo plumbing unless showInternals. */
137
+ function hiddenInternalKind(kind: NodeKind): boolean {
138
+ return kind === "gitState" || kind === "external" || kind === "package" || kind === "entryPoint";
139
+ }
140
+
141
+ function sortKids(ids: string[], byId: Map<string, GraphNode>): string[] {
142
+ return ids
143
+ .slice()
144
+ .sort((a, b) => {
145
+ const la = listLabel(byId.get(a)!);
146
+ const lb = listLabel(byId.get(b)!);
147
+ return la.localeCompare(lb);
148
+ });
149
+ }
150
+
151
+ /** A `count` meta when the detail value is numeric, else a verbatim `text`
152
+ * meta — so a hand-built or corrupt graph still displays what it holds. */
153
+ function countMeta(raw: string, unit: string, phrasing: "attribute" | "prose"): TreeMeta {
154
+ const n = Number(raw);
155
+ if (!Number.isFinite(n)) {
156
+ return { kind: "text", text: phrasing === "attribute" ? `${unit}=${raw}` : `${raw} ${unit}` };
157
+ }
158
+ return { kind: "count", n, unit, phrasing };
159
+ }
160
+
161
+ function metaFor(node: GraphNode): TreeMeta {
162
+ switch (node.kind) {
163
+ case "note":
164
+ return node.detail.updated ? { kind: "relTime", iso: node.detail.updated } : null;
165
+ case "module":
166
+ return node.detail.files ? countMeta(node.detail.files, "files", "attribute") : null;
167
+ case "gitState":
168
+ return node.detail.commit ? { kind: "commit", sha: node.detail.commit } : null;
169
+ case "repository":
170
+ return node.detail.files ? countMeta(node.detail.files, "files", "prose") : null;
171
+ case "package":
172
+ return node.detail.kind ? { kind: "text", text: node.detail.kind } : null;
173
+ case "entryPoint":
174
+ return node.detail.summary ? { kind: "text", text: "summary" } : null;
175
+ default:
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Build the Explore tree rows (port of the page's `listTree`).
182
+ *
183
+ * - Roots: `vault`, `repository` (nodes with no incoming containment edge).
184
+ * - Entry points nest under their prefix module.
185
+ * - Internals hidden by default; the caller toggles `showInternals`.
186
+ * - Filter (query and/or provenance): prune to matches + ancestors, auto-expanding ancestors.
187
+ * - Default expansion (no filter): controlled by `state.expanded`.
188
+ */
189
+ export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
190
+ const idx = indexTree(model);
191
+ const { byId, tree, moduleEntries, roots } = idx;
192
+
193
+ const children = (id: string): string[] => {
194
+ const kids = tree.get(id) ?? [];
195
+ // Drop entry points that have a claiming module (they re-nest under it).
196
+ const filtered = kids.filter((kid) => {
197
+ const n = byId.get(kid);
198
+ if (!n || n.kind !== "entryPoint") return true;
199
+ for (const [, entries] of moduleEntries) {
200
+ if (entries.includes(kid)) return false;
201
+ }
202
+ return true;
203
+ });
204
+ let all = filtered.concat(moduleEntries.get(id) ?? []);
205
+ // Skip kids absent from the node set (corrupt/hand-built graphs): the page
206
+ // never produces these, but a defensive filter keeps the tree robust.
207
+ all = all.filter((k) => byId.has(k));
208
+ if (!state.showInternals) {
209
+ all = all.filter((k) => {
210
+ const n = byId.get(k);
211
+ return !(n && hiddenInternalKind(n.kind));
212
+ });
213
+ }
214
+ return sortKids(all, byId);
215
+ };
216
+
217
+ const filtering = state.provFilter !== null || state.query.length > 0;
218
+
219
+ const matches = (node: GraphNode): boolean => {
220
+ if (state.provFilter !== null && node.provenance !== state.provFilter) return false;
221
+ if (state.query.length > 0 && !listLabel(node).toLowerCase().includes(state.query.toLowerCase())) return false;
222
+ return true;
223
+ };
224
+
225
+ // Pass 1: mark visible nodes (self-match or a visible descendant).
226
+ const visible = new Set<string>();
227
+ const mark = (id: string): boolean => {
228
+ const node = byId.get(id)!;
229
+ let any = false;
230
+ for (const k of children(id)) if (mark(k)) any = true;
231
+ const show = matches(node) || any;
232
+ if (show) visible.add(id);
233
+ return show;
234
+ };
235
+ for (const r of roots) mark(r);
236
+
237
+ const rows: TreeRow[] = [];
238
+ const walk = (id: string, depth: number): void => {
239
+ if (!visible.has(id)) return;
240
+ const node = byId.get(id)!;
241
+ const kids = children(id);
242
+ const visibleKids = kids.filter((k) => visible.has(k));
243
+ const expanded = filtering ? visibleKids.length > 0 : state.expanded.has(id);
244
+ rows.push({
245
+ id,
246
+ depth,
247
+ hasKids: kids.length > 0,
248
+ expanded,
249
+ label: listLabel(node),
250
+ kind: node.kind,
251
+ provenance: node.provenance,
252
+ meta: metaFor(node),
253
+ });
254
+ if (expanded) for (const k of visibleKids) walk(k, depth + 1);
255
+ };
256
+ for (const r of roots) walk(r, 0);
257
+ return rows;
258
+ }
259
+
260
+ /** Empty-state hint for the tree surface (weave-view-tui-design §5.1). */
261
+ export function treeEmptyHint(model: GraphModel): string | null {
262
+ const vault = model.nodes.find((n) => n.kind === "vault");
263
+ if (!vault) return null;
264
+ // no notes and no repository
265
+ const hasNotes = model.nodes.some((n) => n.kind === "note");
266
+ const hasRepo = model.nodes.some((n) => n.kind === "repository");
267
+ if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
268
+ return null;
269
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared row/meta types for the portable view-models (weave-workspace §3).
3
+ *
4
+ * These are *renderer-agnostic*: no terminal widths, no pre-truncation, no
5
+ * DOM. The TUI and the browser workspace both consume them and each applies
6
+ * its own formatting.
7
+ */
8
+
9
+ /** A selectable row in any surface. `target` (when set) is the node id a row
10
+ * jumps to on activation (a link / neighbor). */
11
+ export interface SelectableRow {
12
+ id: string;
13
+ /** Node id this row jumps to on activation (links/backlinks/neighbors). */
14
+ target?: string;
15
+ }
16
+
17
+ /**
18
+ * The dim trailing annotation on a tree row, as structured data.
19
+ *
20
+ * Deliberately *not* a formatted string: a terminal column, an HTML `<span>`,
21
+ * and a `<time>` element each want something different, and a relative time
22
+ * has to be re-derived as the clock moves. `formatTreeMeta` (view/time.ts)
23
+ * reproduces the terminal phrasing; other renderers are free to ignore it.
24
+ */
25
+ export type TreeMeta =
26
+ /** Relative timestamp, e.g. a note's `updated`. Rendered against a clock. */
27
+ | { kind: "relTime"; iso: string }
28
+ /**
29
+ * A counted quantity. `phrasing` is a display hint, not a hard rule:
30
+ * `"attribute"` reads as `unit=n` (a property of the row), `"prose"` reads
31
+ * as `n unit` (a description of it).
32
+ */
33
+ | { kind: "count"; n: number; unit: string; phrasing: "attribute" | "prose" }
34
+ /** A full commit sha; renderers abbreviate to taste. */
35
+ | { kind: "commit"; sha: string }
36
+ /** An opaque, already-human string (a package kind, a marker word). */
37
+ | { kind: "text"; text: string }
38
+ /** No annotation. */
39
+ | null;
package/src/pi/index.ts CHANGED
@@ -13,6 +13,7 @@ import { registerNoteTool } from "./tools/noteTool";
13
13
  import { registerRepoTool } from "./tools/repoTool";
14
14
  import { deepScanRepository, formatDeepScanResult } from "./summarize";
15
15
  import { runWeaveViewTui } from "./viewer/tui/run";
16
+ import { WebWorkspaceController } from "./viewer/web/run";
16
17
 
17
18
  /**
18
19
  * pi-weave — an agent-native knowledge workspace (docs/design.md):
@@ -27,13 +28,21 @@ export default function piWeave(pi: ExtensionAPI): void {
27
28
  registerNoteTool(pi);
28
29
  registerRepoTool(pi);
29
30
 
30
- // /weave-view is the in-terminal explorer (the old browser viewer was
31
- // retired for a pixi.js rewrite); it holds no session resources, so there
32
- // is no per-session lifecycle to tear down.
31
+ // /weave-view opens the browser workspace by default (weave-workspace §13);
32
+ // `tui` selects the in-terminal explorer. The browser workspace owns a
33
+ // server, a file watcher and live SSE streams for the rest of the session,
34
+ // so unlike the TUI it has a real teardown — see `session_shutdown` below.
33
35
  let lastCtx: ExtensionContext | ExtensionCommandContext | null = null;
34
36
  let lastStatusText: string | undefined = undefined;
35
37
  let isActive = false;
36
38
 
39
+ const web = new WebWorkspaceController({
40
+ exec: (command, args) => pi.exec(command, args),
41
+ // A boot or an idle shutdown changes what the status line should say, and
42
+ // neither happens inside a command handler that could refresh it itself.
43
+ onStateChange: () => updateStatus(),
44
+ });
45
+
37
46
  function updateStatus(ctx?: ExtensionContext | ExtensionCommandContext, text?: string): void {
38
47
  if (ctx) lastCtx = ctx;
39
48
  const c = ctx || lastCtx;
@@ -41,10 +50,28 @@ export default function piWeave(pi: ExtensionAPI): void {
41
50
  if (text !== undefined) {
42
51
  lastStatusText = text;
43
52
  }
44
- if (!lastStatusText) {
53
+
54
+ // A running browser workspace is a background process the user cannot
55
+ // otherwise see — an open port on their machine — so it always gets a
56
+ // marker. Resolved *before* the "nothing to show" test, because the port
57
+ // is itself something to show.
58
+ //
59
+ // This ordering is the fix for a real bug: the early return used to sit
60
+ // above this lookup, so a workspace started in a session that never
61
+ // emitted `session_start` (no seeded `lastStatusText`) did not merely
62
+ // fail to gain the marker — it actively cleared the status line while a
63
+ // server was listening. The browser-launch-failure path is exactly that
64
+ // shape, which is why it was the case that caught it.
65
+ const port = web.port();
66
+ const suffix = port === null ? "" : ` · web:${port}`;
67
+
68
+ // Clear only when there is genuinely nothing to say: no base text *and*
69
+ // no server.
70
+ if (!lastStatusText && port === null) {
45
71
  c.ui.setStatus("weave", undefined);
46
72
  return;
47
73
  }
74
+
48
75
  let theme: { fg?: (slot: string, text: string) => string } | undefined;
49
76
  try {
50
77
  theme = (c.ui as unknown as { theme?: { fg?: (slot: string, text: string) => string } })?.theme;
@@ -54,11 +81,19 @@ export default function piWeave(pi: ExtensionAPI): void {
54
81
  const indicator = (isActive || inFlightDeepScans.size > 0)
55
82
  ? (theme?.fg ? theme.fg("accent", "●") : "●")
56
83
  : (theme?.fg ? theme.fg("dim", "○") : "○");
57
- c.ui.setStatus("weave", `${indicator} ${lastStatusText}`);
84
+ // With no base text the marker stands alone (`○ web:51234`) rather than
85
+ // rendering a leading ` · ` separator against nothing.
86
+ const body = lastStatusText ? `${lastStatusText}${suffix}` : `web:${port}`;
87
+ c.ui.setStatus("weave", `${indicator} ${body}`);
58
88
  }
59
89
 
60
90
  pi.on("session_shutdown", async () => {
61
- // The TUI explorer owns no session-scoped resources; nothing to stop.
91
+ // The TUI explorer owns nothing session-scoped, but the browser workspace
92
+ // owns an HTTP server, a recursive file watcher and any number of open SSE
93
+ // streams. `close()` releases all three (server.ts closes the hub and
94
+ // awaits the watcher); a leaked listener outliving the pi session is a
95
+ // real bug, not a tidiness concern.
96
+ await web.close();
62
97
  });
63
98
 
64
99
  pi.on("agent_start", async (_event, ctx) => {
@@ -97,14 +132,26 @@ export default function piWeave(pi: ExtensionAPI): void {
97
132
  });
98
133
 
99
134
  pi.registerCommand("weave-view", {
100
- description: "Open the in-terminal knowledge-graph explorer (vault + repository); reads from disk live",
135
+ description: "Open the knowledge workspace in your browser ('tui' for the in-terminal explorer, '--no-open' to just print the URL)",
101
136
  handler: async (args, ctx) => {
102
- const arg = args.trim().toLowerCase();
103
- if (arg !== "" && arg !== "tui") {
104
- ctx.ui.notify("usage: /weave-view [tui]", "warning");
137
+ const parsed = parseWeaveViewArgs(args);
138
+ if (parsed === null) {
139
+ ctx.ui.notify(WEAVE_VIEW_USAGE, "warning");
105
140
  return;
106
141
  }
107
- await runWeaveViewTui(ctx);
142
+ if (parsed.surface === "tui") {
143
+ await runWeaveViewTui(ctx);
144
+ return;
145
+ }
146
+ const outcome = await web.run(ctx, { open: parsed.open });
147
+ // A browser that would not launch on a machine that has a terminal
148
+ // still leaves the user somewhere to go. The server stays up — the URL
149
+ // in the notification remains valid — and the TUI opens on top of it.
150
+ if (outcome.fallbackToTui && ctx.mode === "tui") await runWeaveViewTui(ctx);
151
+ // Unconditionally, and last: the TUI writes its own status line on
152
+ // close, which would otherwise drop the `· web:PORT` marker for a
153
+ // server that is still running.
154
+ updateStatus(ctx);
108
155
  },
109
156
  });
110
157
 
@@ -156,6 +203,52 @@ export default function piWeave(pi: ExtensionAPI): void {
156
203
  });
157
204
  }
158
205
 
206
+ /* ------------------------------------------------------------------ */
207
+ /* /weave-view argument parsing */
208
+ /* ------------------------------------------------------------------ */
209
+
210
+ export const WEAVE_VIEW_USAGE = "usage: /weave-view [tui|web] [--no-open]";
211
+
212
+ /** Which explorer `/weave-view` was asked for. */
213
+ export interface WeaveViewArgs {
214
+ surface: "tui" | "web";
215
+ /** `false` for `--no-open`. Meaningless for `tui`, which never opens one. */
216
+ open: boolean;
217
+ }
218
+
219
+ /**
220
+ * Parse `/weave-view`'s argument string, or `null` for "print the usage".
221
+ *
222
+ * Bare `/weave-view` is the **browser** workspace (weave-workspace §13):
223
+ * the default is the thing most people want most of the time, and `tui` is
224
+ * one word away for the SSH case. `--no-open` is accepted on the web
225
+ * surface only — silently ignoring it after `tui` would be a lie about what
226
+ * happened.
227
+ *
228
+ * Exported because a parser is exactly the kind of thing that should be
229
+ * tested as a table rather than through eight command invocations.
230
+ */
231
+ export function parseWeaveViewArgs(args: string): WeaveViewArgs | null {
232
+ const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
233
+ let surface: "tui" | "web" = "web";
234
+ let open = true;
235
+ let sawSurface = false;
236
+ for (const token of tokens) {
237
+ if (token === "--no-open") {
238
+ // Rejecting the repeat costs nothing and keeps "did I typo?" honest.
239
+ if (!open) return null;
240
+ open = false;
241
+ continue;
242
+ }
243
+ if (sawSurface) return null;
244
+ if (token !== "tui" && token !== "web") return null;
245
+ surface = token;
246
+ sawSurface = true;
247
+ }
248
+ if (surface === "tui" && !open) return null;
249
+ return { surface, open };
250
+ }
251
+
159
252
  /* ------------------------------------------------------------------ */
160
253
  /* In-flight deep scans (background, cancellable) */
161
254
  /* ------------------------------------------------------------------ */
@@ -21,6 +21,7 @@ import type { ViewNote } from "../../../core/graph/current";
21
21
  import {
22
22
  detailModel,
23
23
  focusModel,
24
+ formatTreeMeta,
24
25
  graphRoots,
25
26
  healthModel,
26
27
  initialState,
@@ -359,8 +360,8 @@ export class WeaveExplorer implements Component {
359
360
  showInternals: this.state.showInternals,
360
361
  provFilter: this.state.provFilter,
361
362
  query: this.state.query,
362
- now: this.nowFn(),
363
363
  });
364
+ const now = this.nowFn();
364
365
  const hint = treeEmptyHint(this.model);
365
366
  if (rows.length === 0 && hint) {
366
367
  return { lines: [{ text: t.fg("dim", hint) }], rows: [] };
@@ -369,7 +370,8 @@ export class WeaveExplorer implements Component {
369
370
  const chev = chevron(r.expanded, r.hasKids);
370
371
  const marker = this.rowMarker(r.kind, r.provenance);
371
372
  const label = sanitizeTerminalText(r.label);
372
- const meta = r.meta ? t.fg("dim", ` ${sanitizeTerminalText(r.meta)}`) : "";
373
+ const metaText = formatTreeMeta(r.meta, now);
374
+ const meta = metaText ? t.fg("dim", ` ${sanitizeTerminalText(metaText)}`) : "";
373
375
  const indent = " ".repeat(r.depth);
374
376
  const body = `${indent}${chev} ${marker}${label}`;
375
377
  const text = meta ? `${body}${meta}` : body;