pi-weave 0.3.8 → 0.3.10
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.
- package/README.md +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GraphTheme } from "../../shared/themes";
|
|
2
2
|
import type { Point } from "../../shared/layout";
|
|
3
|
-
import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
|
|
4
|
-
import { edgeReducer, fadeStep, frameBox, graphSettings, nodeReducer } from "./graph.model";
|
|
3
|
+
import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
|
+
import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
5
5
|
import type { ProjectedGraph } from "./project";
|
|
6
6
|
import { positionsOf, project, syncPositions } from "./project";
|
|
7
7
|
|
|
@@ -56,6 +56,7 @@ export interface SigmaLike {
|
|
|
56
56
|
setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
|
|
57
57
|
setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride): unknown;
|
|
58
58
|
setSetting(key: "enableCameraPanning", value: boolean): unknown;
|
|
59
|
+
setSetting(key: "labelRenderedSizeThreshold", value: number): unknown;
|
|
59
60
|
setGraph(graph: ProjectedGraph): unknown;
|
|
60
61
|
/**
|
|
61
62
|
* Freeze the graph→viewport normalization onto a fixed box.
|
|
@@ -129,6 +130,10 @@ export function sigmaRenderer(
|
|
|
129
130
|
let highlight: ReadonlySet<string> | null = null;
|
|
130
131
|
/** The selection inside that neighbourhood — see `nodeReducer`. */
|
|
131
132
|
let selected: string | null = null;
|
|
133
|
+
/** Which treatment `highlight` gets — a neighbourhood or a spotlight. */
|
|
134
|
+
let style: HighlightStyle = "neighbourhood";
|
|
135
|
+
/** Recall-trail glows, layered over the selection — see `trailOverride`. */
|
|
136
|
+
let trail: ReadonlyMap<string, TrailGlow> = new Map();
|
|
132
137
|
/**
|
|
133
138
|
* How present the highlight currently *looks*, and where it is heading.
|
|
134
139
|
*
|
|
@@ -173,9 +178,13 @@ export function sigmaRenderer(
|
|
|
173
178
|
* until the next unrelated frame.
|
|
174
179
|
*/
|
|
175
180
|
const applyReducers = (instance: SigmaLike): void => {
|
|
176
|
-
const nodes = nodeReducer(highlight, selected, fade);
|
|
177
|
-
const edges = edgeReducer(highlight, selected, fade);
|
|
178
|
-
|
|
181
|
+
const nodes = nodeReducer(highlight, selected, fade, style);
|
|
182
|
+
const edges = edgeReducer(highlight, selected, fade, style);
|
|
183
|
+
const glows = trail;
|
|
184
|
+
instance.setSetting("nodeReducer", (id, data) => {
|
|
185
|
+
const reduced = { ...data, ...nodes(id, data, scheme) };
|
|
186
|
+
return { ...reduced, ...trailOverride(glows.get(id), reduced, scheme, data.label) };
|
|
187
|
+
});
|
|
179
188
|
instance.setSetting("edgeReducer", (key, data) => ({ ...data, ...edges(key, data, scheme) }));
|
|
180
189
|
};
|
|
181
190
|
|
|
@@ -246,6 +255,7 @@ export function sigmaRenderer(
|
|
|
246
255
|
instance.on("upNode", () => endDrag(dragging));
|
|
247
256
|
instance.on("upStage", () => endDrag(null));
|
|
248
257
|
applyReducers(instance);
|
|
258
|
+
instance.setSetting("labelRenderedSizeThreshold", labelThreshold(graph.order));
|
|
249
259
|
// Freeze the view box for whatever graph arrived before mount, so the
|
|
250
260
|
// first frame is already framed and stable — see `SigmaLike.setCustomBBox`.
|
|
251
261
|
instance.setCustomBBox(boxOf());
|
|
@@ -255,6 +265,8 @@ export function sigmaRenderer(
|
|
|
255
265
|
setGraph(next: RenderGraph) {
|
|
256
266
|
graph = project(next);
|
|
257
267
|
sigma?.setGraph(graph);
|
|
268
|
+
// A graph that crossed the small-graph line gains or loses its labels.
|
|
269
|
+
sigma?.setSetting("labelRenderedSizeThreshold", labelThreshold(graph.order));
|
|
258
270
|
// Re-frame on a shape change: new nodes legitimately change the extent,
|
|
259
271
|
// and the box is what keeps the *drag* frames from also changing it.
|
|
260
272
|
// Deliberately not touched by `setPositions` — a settling simulation
|
|
@@ -269,10 +281,11 @@ export function sigmaRenderer(
|
|
|
269
281
|
sigma?.refresh();
|
|
270
282
|
},
|
|
271
283
|
|
|
272
|
-
setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null) {
|
|
284
|
+
setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null, nextStyle: HighlightStyle = "neighbourhood") {
|
|
273
285
|
if (clock === undefined) {
|
|
274
286
|
highlight = next;
|
|
275
287
|
selected = selectedId;
|
|
288
|
+
style = nextStyle;
|
|
276
289
|
fade = next === null ? 0 : 1;
|
|
277
290
|
fadeTo = fade;
|
|
278
291
|
if (sigma !== null) applyReducers(sigma);
|
|
@@ -287,15 +300,23 @@ export function sigmaRenderer(
|
|
|
287
300
|
} else {
|
|
288
301
|
// A new neighbourhood replaces the old one immediately — a cross-fade
|
|
289
302
|
// between two of them is a picture of neither — and only the *presence*
|
|
290
|
-
// of the highlight is animated.
|
|
303
|
+
// of the highlight is animated. The style swaps with the set; a clear
|
|
304
|
+
// (above) keeps the outgoing style so a spotlight fades out *as* one.
|
|
291
305
|
highlight = next;
|
|
292
306
|
selected = selectedId;
|
|
307
|
+
style = nextStyle;
|
|
293
308
|
fadeTo = 1;
|
|
294
309
|
}
|
|
295
310
|
if (sigma !== null) applyReducers(sigma);
|
|
296
311
|
if (fade !== fadeTo) armFade();
|
|
297
312
|
},
|
|
298
313
|
|
|
314
|
+
/** Replace the recall-trail glows. The caller re-sends as the fade ticks. */
|
|
315
|
+
setTrail(next: ReadonlyMap<string, TrailGlow>) {
|
|
316
|
+
trail = next;
|
|
317
|
+
if (sigma !== null) applyReducers(sigma);
|
|
318
|
+
},
|
|
319
|
+
|
|
299
320
|
onSelect(handler: (id: string | null) => void) {
|
|
300
321
|
select = handler;
|
|
301
322
|
},
|
|
@@ -322,8 +343,17 @@ export function sigmaRenderer(
|
|
|
322
343
|
// onto that box. `animatedReset` settles when the animation ends. Nothing
|
|
323
344
|
// awaits a camera move, and a rejection from a camera killed mid-flight
|
|
324
345
|
// is noise.
|
|
325
|
-
sigma
|
|
326
|
-
|
|
346
|
+
const instance = sigma;
|
|
347
|
+
if (instance === null) return;
|
|
348
|
+
instance.setCustomBBox(boxOf());
|
|
349
|
+
// Sigma leaves edges out of frames drawn while the camera moves
|
|
350
|
+
// (`hideEdgesOnMove`) and repaints when the camera changes. A reset onto
|
|
351
|
+
// where the camera already is changes nothing, so the frame drawn during
|
|
352
|
+
// it, without edges, stayed on screen until something else repainted.
|
|
353
|
+
// One refresh once it settles draws them back.
|
|
354
|
+
instance.getCamera().animatedReset().then(() => {
|
|
355
|
+
if (sigma === instance) instance.refresh();
|
|
356
|
+
}, () => {});
|
|
327
357
|
},
|
|
328
358
|
|
|
329
359
|
positions() {
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph spotlight: light up the nodes a query names (UI refresh, G1).
|
|
3
|
+
*
|
|
4
|
+
* Type a word into the field floating over the graph and the nodes it names
|
|
5
|
+
* stay at full strength, grow and carry their labels while everything else
|
|
6
|
+
* drops back to faint dots; the lines *between* lit nodes light up too. A side
|
|
7
|
+
* sheet explains the node you are looking at, including why it lit.
|
|
8
|
+
*
|
|
9
|
+
* Everything the feature decides lives here, for §10's reason: the column
|
|
10
|
+
* component is a canvas plus a few overlays, and a branch inside a `.tsx` is a
|
|
11
|
+
* branch no test can reach. What this module answers:
|
|
12
|
+
*
|
|
13
|
+
* - {@link spotlightOf} — which nodes a query lights, and why each one did.
|
|
14
|
+
* - {@link litOnCanvas} — the same set in terms of what is *drawn*, so a lit
|
|
15
|
+
* note inside a collapsed folder lights the folder standing in for it.
|
|
16
|
+
* - {@link graphSheet} — the side sheet's content for the previewed node.
|
|
17
|
+
* - {@link spotlightEscapes} — whether `Esc` clears the query or falls through
|
|
18
|
+
* to the shell's existing "clear selection".
|
|
19
|
+
*
|
|
20
|
+
* The rendering itself is not here: the lit set is handed to the renderer's
|
|
21
|
+
* ordinary `setHighlight` with the `spotlight` style (`graph.model.ts`), the
|
|
22
|
+
* same path and the same fade a selection's neighbourhood takes.
|
|
23
|
+
*
|
|
24
|
+
* ## Tier rules (§2)
|
|
25
|
+
*
|
|
26
|
+
* `src/web/client/**`: wire types from `../../shared/wire`, core view-models
|
|
27
|
+
* only through `../../shared/view`. No DOM type is named, so the root
|
|
28
|
+
* `tsconfig.json` compiles the tests.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { relTime } from "../../shared/view";
|
|
32
|
+
import type { GraphPayload, WireGraphEdge, WireGraphNode, WireNoteSource } from "../../shared/wire";
|
|
33
|
+
import { excerptOf, stripMarkdown } from "../note/note.model";
|
|
34
|
+
import type { IconName } from "../shell/icons.model";
|
|
35
|
+
import { provenanceGlyph, provenanceTitle } from "../tree/tree.model";
|
|
36
|
+
import { graphPreview } from "./column.model";
|
|
37
|
+
|
|
38
|
+
// --- copy ------------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** The field's placeholder: what the spotlight can find, in the order people look. */
|
|
41
|
+
export const SPOTLIGHT_PLACEHOLDER = "Light up notes, tags or code";
|
|
42
|
+
|
|
43
|
+
/** The field's accessible name. */
|
|
44
|
+
export const SPOTLIGHT_LABEL = "Light up in graph";
|
|
45
|
+
|
|
46
|
+
/** The clear control's label and hint — it is also what `Esc` does. */
|
|
47
|
+
export const SPOTLIGHT_CLEAR = "Esc";
|
|
48
|
+
export const SPOTLIGHT_CLEAR_HINT = "Clear the spotlight (Esc)";
|
|
49
|
+
|
|
50
|
+
/** The legend while a spotlight is up, replacing the neighbourhood's. */
|
|
51
|
+
export const SPOTLIGHT_LEGEND = { lit: "lit", dimmed: "not lit" } as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Why a node lit when only the search palette found it: its words are in the
|
|
55
|
+
* note's text, which the graph payload does not carry.
|
|
56
|
+
*/
|
|
57
|
+
export const SEARCH_REASON = "search match";
|
|
58
|
+
|
|
59
|
+
/** The count chip: `5 lit`, or a plain sentence when nothing matched. */
|
|
60
|
+
export function litLabel(count: number): string {
|
|
61
|
+
return count === 0 ? "no matches" : `${count} lit`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- the query -----------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** One word of a query. */
|
|
67
|
+
export interface SpotlightTerm {
|
|
68
|
+
/** Lower-cased, never empty. */
|
|
69
|
+
readonly text: string;
|
|
70
|
+
/** Written as `#word`: matches tags and nothing else. */
|
|
71
|
+
readonly tagOnly: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Split a query into terms.
|
|
76
|
+
*
|
|
77
|
+
* Words, not a phrase: `launch draft` lights "Launch post draft", which a
|
|
78
|
+
* substring match on the whole query would miss. A leading `#` makes a term
|
|
79
|
+
* tag-only, the way tags are written everywhere else in the workspace; a bare
|
|
80
|
+
* `#` names nothing and is dropped.
|
|
81
|
+
*/
|
|
82
|
+
export function spotlightTerms(query: string): SpotlightTerm[] {
|
|
83
|
+
const terms: SpotlightTerm[] = [];
|
|
84
|
+
for (const word of query.toLowerCase().split(/\s+/)) {
|
|
85
|
+
const tagOnly = word.startsWith("#");
|
|
86
|
+
const text = tagOnly ? word.replace(/^#+/, "") : word;
|
|
87
|
+
if (text !== "") terms.push({ text, tagOnly });
|
|
88
|
+
}
|
|
89
|
+
return terms;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- matching -------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** A node's matching path: the slug or repo path its id is derived from. */
|
|
95
|
+
export function nodePath(node: WireGraphNode): string {
|
|
96
|
+
const fromDetail = node.detail.path ?? node.detail.slug;
|
|
97
|
+
if (typeof fromDetail === "string" && fromDetail !== "") return fromDetail;
|
|
98
|
+
const colon = node.id.indexOf(":");
|
|
99
|
+
return colon === -1 ? "" : node.id.slice(colon + 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Note node id → its tags, from the payload's structured tag index (never `detail.tags`). */
|
|
103
|
+
export function tagsByNode(payload: GraphPayload): Map<string, string[]> {
|
|
104
|
+
const out = new Map<string, string[]>();
|
|
105
|
+
for (const [tag, slugs] of Object.entries(payload.tags)) {
|
|
106
|
+
for (const slug of slugs) {
|
|
107
|
+
const id = `note:${slug}`;
|
|
108
|
+
const list = out.get(id);
|
|
109
|
+
if (list === undefined) out.set(id, [tag]);
|
|
110
|
+
else if (!list.includes(tag)) list.push(tag);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The reasons one node matched, or `null` when some term found nothing. */
|
|
117
|
+
export function matchReasons(node: WireGraphNode, tags: readonly string[], terms: readonly SpotlightTerm[]): string[] | null {
|
|
118
|
+
const title = node.label.toLowerCase();
|
|
119
|
+
const path = nodePath(node).toLowerCase();
|
|
120
|
+
let byTitle = false;
|
|
121
|
+
let byPath = false;
|
|
122
|
+
const byTag: string[] = [];
|
|
123
|
+
for (const term of terms) {
|
|
124
|
+
const inTitle = !term.tagOnly && title.includes(term.text);
|
|
125
|
+
const tagHits = tags.filter((tag) => tag.toLowerCase().includes(term.text));
|
|
126
|
+
// The path is the weakest reason — a note's slug usually spells its own
|
|
127
|
+
// title — so it is only *named* when nothing better explains the term.
|
|
128
|
+
const inPath = !term.tagOnly && !inTitle && tagHits.length === 0 && path.includes(term.text);
|
|
129
|
+
if (!inTitle && tagHits.length === 0 && !inPath) return null;
|
|
130
|
+
byTitle ||= inTitle;
|
|
131
|
+
byPath ||= inPath;
|
|
132
|
+
for (const tag of tagHits) if (!byTag.includes(tag)) byTag.push(tag);
|
|
133
|
+
}
|
|
134
|
+
// Stable reading order — title, then tags in the index's order, then path —
|
|
135
|
+
// whatever order the words were typed in.
|
|
136
|
+
const ordered = tags.filter((tag) => byTag.includes(tag));
|
|
137
|
+
return [...(byTitle ? ["title"] : []), ...ordered.map((tag) => `tag #${tag}`), ...(byPath ? ["path"] : [])];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a query lit. `null` from {@link spotlightOf} means "no spotlight at all". */
|
|
141
|
+
export interface Spotlight {
|
|
142
|
+
readonly terms: readonly SpotlightTerm[];
|
|
143
|
+
/** Lit node ids, from the full payload — collapsed nodes included. */
|
|
144
|
+
readonly lit: ReadonlySet<string>;
|
|
145
|
+
/** Lit node id → why it lit: `title`, `tag #x`, `path`, `search match`. */
|
|
146
|
+
readonly reasons: ReadonlyMap<string, readonly string[]>;
|
|
147
|
+
/** How many nodes lit. The chip's number. */
|
|
148
|
+
readonly count: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Light up the nodes a query names.
|
|
153
|
+
*
|
|
154
|
+
* Case-insensitive, word by word: a node lights when **every** term is found
|
|
155
|
+
* in its title, one of its tags or its path. Matching runs over the whole
|
|
156
|
+
* payload rather than the drawn graph, so the count is the same whatever is
|
|
157
|
+
* expanded — {@link litOnCanvas} maps the answer onto the canvas.
|
|
158
|
+
*
|
|
159
|
+
* `found` is what the search palette listed for this same query, when the
|
|
160
|
+
* spotlight came from its "Light up in graph". Search reads note text the
|
|
161
|
+
* payload does not have, so a note it found by a word in its body lights too
|
|
162
|
+
* ({@link SEARCH_REASON}), and the graph never says "no matches" for results
|
|
163
|
+
* the palette just showed. Ids not in the payload are skipped.
|
|
164
|
+
*
|
|
165
|
+
* `null` for an empty (or whitespace, or bare `#`) query: no spotlight, which
|
|
166
|
+
* the column renders as the ordinary graph. A query that matches nothing is
|
|
167
|
+
* *not* `null` — it is an empty set, and the canvas dims entirely, which is
|
|
168
|
+
* the honest picture of "nothing here is called that".
|
|
169
|
+
*/
|
|
170
|
+
export function spotlightOf(payload: GraphPayload | null, query: string, found: readonly string[] = []): Spotlight | null {
|
|
171
|
+
const terms = spotlightTerms(query);
|
|
172
|
+
if (terms.length === 0) return null;
|
|
173
|
+
const lit = new Set<string>();
|
|
174
|
+
const reasons = new Map<string, readonly string[]>();
|
|
175
|
+
if (payload !== null) {
|
|
176
|
+
const tags = tagsByNode(payload);
|
|
177
|
+
for (const node of payload.model.nodes) {
|
|
178
|
+
if (lit.has(node.id)) continue;
|
|
179
|
+
const why = matchReasons(node, tags.get(node.id) ?? [], terms);
|
|
180
|
+
if (why === null) continue;
|
|
181
|
+
lit.add(node.id);
|
|
182
|
+
reasons.set(node.id, why);
|
|
183
|
+
}
|
|
184
|
+
const searched = new Set(found);
|
|
185
|
+
for (const node of payload.model.nodes) {
|
|
186
|
+
if (lit.has(node.id) || !searched.has(node.id)) continue;
|
|
187
|
+
lit.add(node.id);
|
|
188
|
+
reasons.set(node.id, [SEARCH_REASON]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { terms, lit, reasons, count: lit.size };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The lit set in terms of what is drawn.
|
|
196
|
+
*
|
|
197
|
+
* A lit node hidden inside a collapsed cluster is not on the canvas, so the
|
|
198
|
+
* cluster representing it lights instead — the same retargeting the
|
|
199
|
+
* neighbourhood highlight relies on (`clusterAggregate`'s `representative`).
|
|
200
|
+
* `null` in, `null` out.
|
|
201
|
+
*/
|
|
202
|
+
export function litOnCanvas(spot: Spotlight | null, representative: ReadonlyMap<string, string>): Set<string> | null {
|
|
203
|
+
if (spot === null) return null;
|
|
204
|
+
const out = new Set<string>();
|
|
205
|
+
for (const id of spot.lit) out.add(representative.get(id) ?? id);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `title, tag #launch` — the sheet's "Lit because" line — or `null` for an unlit node. */
|
|
210
|
+
export function litBecause(spot: Spotlight | null, id: string): string | null {
|
|
211
|
+
const why = spot?.reasons.get(id);
|
|
212
|
+
return why === undefined || why.length === 0 ? null : why.join(", ");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `Esc` clears the query.
|
|
217
|
+
*
|
|
218
|
+
* Inside the field, a query is what `Esc` clears — the habit of every search
|
|
219
|
+
* box. Elsewhere in the graph it clears the query only when no sheet is open:
|
|
220
|
+
* an open sheet is the nearer thing, and closing it is the shell's existing
|
|
221
|
+
* `Esc` (clear the selection). With no query there is nothing to clear here,
|
|
222
|
+
* so `Esc` keeps exactly the behaviour it had before the spotlight existed.
|
|
223
|
+
*/
|
|
224
|
+
export function spotlightEscapes(query: string, inField: boolean, sheetOpen: boolean): boolean {
|
|
225
|
+
return query !== "" && (inField || !sheetOpen);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- marking the words ------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** A run of text, marked when it is one of the query's words. */
|
|
231
|
+
export interface MarkedPart {
|
|
232
|
+
readonly text: string;
|
|
233
|
+
readonly hit: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Split `text` into runs, marking every occurrence of a term.
|
|
238
|
+
*
|
|
239
|
+
* Case-insensitive, left to right, earliest match first and the longest term
|
|
240
|
+
* at a tie, never overlapping — so `launch` inside "Launches" marks `Launch`
|
|
241
|
+
* and leaves `es` plain. Tag-only terms mark too: the sheet's text is where
|
|
242
|
+
* the reader looks for the word they typed, whatever field it matched.
|
|
243
|
+
*/
|
|
244
|
+
export function markParts(text: string, terms: readonly SpotlightTerm[]): MarkedPart[] {
|
|
245
|
+
const words = [...new Set(terms.map((term) => term.text))].sort((a, b) => b.length - a.length);
|
|
246
|
+
const lower = text.toLowerCase();
|
|
247
|
+
const parts: MarkedPart[] = [];
|
|
248
|
+
let at = 0;
|
|
249
|
+
let plain = "";
|
|
250
|
+
while (at < text.length) {
|
|
251
|
+
const word = words.find((candidate) => lower.startsWith(candidate, at));
|
|
252
|
+
if (word === undefined) {
|
|
253
|
+
plain += text[at];
|
|
254
|
+
at += 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (plain !== "") parts.push({ text: plain, hit: false });
|
|
258
|
+
plain = "";
|
|
259
|
+
parts.push({ text: text.slice(at, at + word.length), hit: true });
|
|
260
|
+
at += word.length;
|
|
261
|
+
}
|
|
262
|
+
if (plain !== "") parts.push({ text: plain, hit: false });
|
|
263
|
+
return parts;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- the side sheet --------------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/** The sheet's text block's heading. */
|
|
269
|
+
export type SheetTextKind = "TLDR" | "Summary" | "Preview";
|
|
270
|
+
|
|
271
|
+
/** The sheet's text block: a note's TLDR, a summary, or the note's opening words. */
|
|
272
|
+
export interface SheetText {
|
|
273
|
+
readonly heading: SheetTextKind;
|
|
274
|
+
readonly parts: readonly MarkedPart[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One row of the sheet's Connected list. */
|
|
278
|
+
export interface SheetRow {
|
|
279
|
+
readonly id: string;
|
|
280
|
+
readonly label: string;
|
|
281
|
+
readonly lit: boolean;
|
|
282
|
+
/** The row's accessible name: the dot says "lit" to the eye, this says it aloud. */
|
|
283
|
+
readonly ariaLabel: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Who wrote a note, as the sheet says it. */
|
|
287
|
+
export interface SheetWho {
|
|
288
|
+
readonly provenance: WireNoteSource;
|
|
289
|
+
readonly glyph: string;
|
|
290
|
+
readonly word: string;
|
|
291
|
+
readonly title: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Everything the side sheet renders for one node. */
|
|
295
|
+
export interface GraphSheet {
|
|
296
|
+
readonly id: string;
|
|
297
|
+
readonly title: string;
|
|
298
|
+
readonly kind: string;
|
|
299
|
+
readonly icon: IconName;
|
|
300
|
+
/** Where it lives: `projects`, `projects / 2026`, `Vault`. Empty for a root. */
|
|
301
|
+
readonly crumb: string;
|
|
302
|
+
readonly who: SheetWho | null;
|
|
303
|
+
/** `2h ago`, or `""` when the node carries no time. */
|
|
304
|
+
readonly when: string;
|
|
305
|
+
readonly text: SheetText | null;
|
|
306
|
+
/** The "Lit because" line, or `null` when the node did not light. */
|
|
307
|
+
readonly litBecause: string | null;
|
|
308
|
+
readonly connected: readonly SheetRow[];
|
|
309
|
+
/** All connections, of which {@link connected} shows the first few. */
|
|
310
|
+
readonly connectedTotal: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** How many connections the sheet lists before it stops. */
|
|
314
|
+
export const CONNECTED_LIMIT = 6;
|
|
315
|
+
|
|
316
|
+
/** The sheet's section heading and its overflow line. */
|
|
317
|
+
export const CONNECTED_HEADING = "Connected";
|
|
318
|
+
export function connectedMore(shown: number, total: number): string | null {
|
|
319
|
+
return total > shown ? `+${total - shown} more` : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The sheet's labels. */
|
|
323
|
+
export const SHEET_LABEL = "Graph node details";
|
|
324
|
+
export const SHEET_CLOSE = "Close details";
|
|
325
|
+
export const SHEET_OPEN = "Open";
|
|
326
|
+
export const SHEET_OPEN_SPLIT = "Open in split";
|
|
327
|
+
export const LIT_BECAUSE = "Lit because:";
|
|
328
|
+
|
|
329
|
+
/** Provenance → the word the sheet's meta line uses. The replay's vocabulary. */
|
|
330
|
+
const WHO_WORDS: Readonly<Record<WireNoteSource, string>> = {
|
|
331
|
+
human: "You",
|
|
332
|
+
agent: "Agent",
|
|
333
|
+
generated: "Generated",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** The edge kinds that are hierarchy rather than content. */
|
|
337
|
+
const STRUCTURAL = new Set<WireGraphEdge["kind"]>(["contains", "anchored-at"]);
|
|
338
|
+
|
|
339
|
+
/** The containment parent of every node, first edge wins (as `clusterAggregate` attributes). */
|
|
340
|
+
function parentsOf(edges: readonly WireGraphEdge[]): Map<string, string> {
|
|
341
|
+
const out = new Map<string, string>();
|
|
342
|
+
for (const edge of edges) {
|
|
343
|
+
if (STRUCTURAL.has(edge.kind) && !out.has(edge.target) && edge.source !== edge.target) out.set(edge.target, edge.source);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The deepest crumb segments shown before an ellipsis takes over. */
|
|
349
|
+
export const CRUMB_DEPTH = 3;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Where a node lives, as a breadcrumb of its containment ancestors.
|
|
353
|
+
*
|
|
354
|
+
* The root itself is left off when there is anything below it — "projects"
|
|
355
|
+
* says where a note is, "Vault / projects" says it twice — and kept when it is
|
|
356
|
+
* the only ancestor, because a root note's crumb is the root. Deep chains keep
|
|
357
|
+
* their last {@link CRUMB_DEPTH} folders behind an ellipsis. Cycle-safe.
|
|
358
|
+
*/
|
|
359
|
+
export function crumbOf(payload: GraphPayload, id: string): string {
|
|
360
|
+
const parents = parentsOf(payload.model.edges);
|
|
361
|
+
const labels = new Map(payload.model.nodes.map((node) => [node.id, node.label] as const));
|
|
362
|
+
const chain: string[] = [];
|
|
363
|
+
const seen = new Set<string>([id]);
|
|
364
|
+
let at = parents.get(id);
|
|
365
|
+
while (at !== undefined && !seen.has(at)) {
|
|
366
|
+
seen.add(at);
|
|
367
|
+
chain.unshift(labels.get(at) ?? at);
|
|
368
|
+
at = parents.get(at);
|
|
369
|
+
}
|
|
370
|
+
const below = chain.length > 1 ? chain.slice(1) : chain;
|
|
371
|
+
const shown = below.length > CRUMB_DEPTH ? ["…", ...below.slice(-CRUMB_DEPTH)] : below;
|
|
372
|
+
return shown.join(" / ");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The TLDR a finalized note opens with, from its preview.
|
|
377
|
+
*
|
|
378
|
+
* The notepad skill writes `**TLDR:**` directly under the title (skills/
|
|
379
|
+
* weave-notepad). The preview is the body's first characters with whitespace
|
|
380
|
+
* flattened, so the block ends at the next heading or the next bold label;
|
|
381
|
+
* checkbox and bullet markers that lost their line starts are turned back
|
|
382
|
+
* into readable separators. `null` when the note has no TLDR.
|
|
383
|
+
*/
|
|
384
|
+
export function tldrOf(preview: string): string | null {
|
|
385
|
+
const found = /\*\*TL;?DR:?\*\*:?|(?:^|\s)TL;?DR:/i.exec(preview);
|
|
386
|
+
if (found === null) return null;
|
|
387
|
+
let rest = preview.slice(found.index + found[0].length);
|
|
388
|
+
const stop = rest.search(/(?:^|\s)#{1,6}\s|\*\*[^*]+:\*\*/);
|
|
389
|
+
if (stop >= 0) rest = rest.slice(0, stop);
|
|
390
|
+
const text = stripMarkdown(
|
|
391
|
+
rest
|
|
392
|
+
.replace(/(?:^|\s)[-*+]\s+\[[xX]\]\s*/g, " ☑ ")
|
|
393
|
+
.replace(/(?:^|\s)[-*+]\s+\[ \]\s*/g, " ☐ ")
|
|
394
|
+
.replace(/(?:^|\s)[-*+]\s+/g, " · "),
|
|
395
|
+
)
|
|
396
|
+
.replace(/\s+/g, " ")
|
|
397
|
+
.replace(/^\s*·\s*/, "")
|
|
398
|
+
.trim();
|
|
399
|
+
return text === "" ? null : text;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** How long the sheet's text block may run. */
|
|
403
|
+
export const SHEET_TEXT_LIMIT = 200;
|
|
404
|
+
|
|
405
|
+
/** The sheet's text block for a node, or `null` when the payload has none. */
|
|
406
|
+
export function sheetText(node: WireGraphNode, terms: readonly SpotlightTerm[]): SheetText | null {
|
|
407
|
+
const preview = node.detail.preview ?? "";
|
|
408
|
+
const tldr = tldrOf(preview);
|
|
409
|
+
if (tldr !== null) return { heading: "TLDR", parts: markParts(excerptOf(tldr, SHEET_TEXT_LIMIT), terms) };
|
|
410
|
+
const summary = node.detail.summary ?? node.detail.description ?? "";
|
|
411
|
+
if (summary.trim() !== "") return { heading: "Summary", parts: markParts(excerptOf(summary, SHEET_TEXT_LIMIT), terms) };
|
|
412
|
+
const opening = excerptOf(preview, SHEET_TEXT_LIMIT);
|
|
413
|
+
return opening === "" ? null : { heading: "Preview", parts: markParts(opening, terms) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The node's connections, as the sheet lists them.
|
|
418
|
+
*
|
|
419
|
+
* Content edges first — links and mentions, in either direction — because
|
|
420
|
+
* "connected" means what the node is *about*, not which folder it sits in.
|
|
421
|
+
* A node with no content edges (a folder, a module) lists its children
|
|
422
|
+
* instead. Lit connections come first, then alphabetical.
|
|
423
|
+
*/
|
|
424
|
+
export function connectionsOf(payload: GraphPayload, id: string, spot: Spotlight | null): SheetRow[] {
|
|
425
|
+
const byId = new Map(payload.model.nodes.map((node) => [node.id, node] as const));
|
|
426
|
+
const collect = (keep: (edge: WireGraphEdge) => string | null): string[] => {
|
|
427
|
+
const out: string[] = [];
|
|
428
|
+
for (const edge of payload.model.edges) {
|
|
429
|
+
const other = keep(edge);
|
|
430
|
+
if (other !== null && other !== id && byId.has(other) && !out.includes(other)) out.push(other);
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
};
|
|
434
|
+
const content = collect((edge) => (STRUCTURAL.has(edge.kind) ? null : edge.source === id ? edge.target : edge.target === id ? edge.source : null));
|
|
435
|
+
const ids = content.length > 0 ? content : collect((edge) => (STRUCTURAL.has(edge.kind) && edge.source === id ? edge.target : null));
|
|
436
|
+
const rows = ids.map((other) => {
|
|
437
|
+
const node = byId.get(other) as WireGraphNode;
|
|
438
|
+
const lit = spot?.lit.has(other) ?? false;
|
|
439
|
+
return { id: other, label: node.label, lit, ariaLabel: lit ? `${node.label} (lit)` : node.label };
|
|
440
|
+
});
|
|
441
|
+
return rows.sort((a, b) => Number(b.lit) - Number(a.lit) || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The side sheet for the previewed node, or `null` when there is none.
|
|
446
|
+
*
|
|
447
|
+
* Built from the graph payload alone — no extra request: the node's label,
|
|
448
|
+
* kind and `detail` (provenance, `updated`, the preview the TLDR is read
|
|
449
|
+
* from, a deep-scan summary), its containment parents for the crumb and its
|
|
450
|
+
* edges for the Connected list. `now` is injected, per AGENTS.md.
|
|
451
|
+
*/
|
|
452
|
+
export function graphSheet(payload: GraphPayload | null, id: string | null, spot: Spotlight | null, now: number): GraphSheet | null {
|
|
453
|
+
const preview = graphPreview(payload, id);
|
|
454
|
+
if (preview === null || payload === null) return null;
|
|
455
|
+
const node = payload.model.nodes.find((candidate) => candidate.id === preview.id) as WireGraphNode;
|
|
456
|
+
const connected = connectionsOf(payload, preview.id, spot);
|
|
457
|
+
return {
|
|
458
|
+
id: preview.id,
|
|
459
|
+
title: preview.title,
|
|
460
|
+
kind: preview.kind,
|
|
461
|
+
icon: preview.icon,
|
|
462
|
+
crumb: crumbOf(payload, preview.id),
|
|
463
|
+
who: node.provenance === null
|
|
464
|
+
? null
|
|
465
|
+
: { provenance: node.provenance, glyph: provenanceGlyph(node.provenance), word: WHO_WORDS[node.provenance], title: provenanceTitle(node.provenance) },
|
|
466
|
+
when: relTime(node.detail.updated, now),
|
|
467
|
+
text: sheetText(node, spot?.terms ?? []),
|
|
468
|
+
litBecause: litBecause(spot, preview.id),
|
|
469
|
+
connected: connected.slice(0, CONNECTED_LIMIT),
|
|
470
|
+
connectedTotal: connected.length,
|
|
471
|
+
};
|
|
472
|
+
}
|