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.
Files changed (80) hide show
  1. package/README.md +21 -17
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/context/context.model.ts +5 -0
  31. package/src/web/client/dist/app.js +499 -129
  32. package/src/web/client/graph/Graph.tsx +166 -14
  33. package/src/web/client/graph/column.model.ts +59 -2
  34. package/src/web/client/graph/graph.model.ts +183 -7
  35. package/src/web/client/graph/renderer.ts +39 -9
  36. package/src/web/client/graph/spotlight.model.ts +472 -0
  37. package/src/web/client/note/Note.tsx +370 -66
  38. package/src/web/client/note/Replay.tsx +139 -0
  39. package/src/web/client/note/drafts.ts +115 -11
  40. package/src/web/client/note/editbar.model.ts +153 -0
  41. package/src/web/client/note/note.model.ts +82 -1
  42. package/src/web/client/note/replay.model.ts +248 -0
  43. package/src/web/client/search/SearchPalette.tsx +103 -34
  44. package/src/web/client/search/search.model.ts +533 -26
  45. package/src/web/client/shell/ContextRail.tsx +7 -0
  46. package/src/web/client/shell/Settings.tsx +21 -1
  47. package/src/web/client/shell/Shell.tsx +93 -27
  48. package/src/web/client/shell/StatusBar.tsx +1 -0
  49. package/src/web/client/shell/icons.model.ts +68 -1
  50. package/src/web/client/shell/keys.model.ts +31 -8
  51. package/src/web/client/shell/shell.model.ts +48 -1
  52. package/src/web/client/shell/theme.model.ts +3 -0
  53. package/src/web/client/shell/theme.ts +354 -32
  54. package/src/web/client/shell/workspace.css.ts +40 -9
  55. package/src/web/client/trail/TrailStrip.tsx +55 -0
  56. package/src/web/client/trail/trail.model.ts +195 -0
  57. package/src/web/client/trail/trail.ts +45 -0
  58. package/src/web/client/tree/Tree.tsx +7 -3
  59. package/src/web/client/tree/tree.model.ts +2 -5
  60. package/src/web/server/page.ts +1 -1
  61. package/src/web/server/routes.ts +83 -3
  62. package/src/web/shared/logo.ts +1 -1
  63. package/src/web/shared/themes.ts +1 -1
  64. package/src/web/shared/view.ts +10 -4
  65. package/src/web/shared/wire.ts +74 -1
  66. package/src/web/shared/workspace.ts +10 -2
  67. package/src/core/view/health.ts +0 -160
  68. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  69. package/src/pi/viewer/tui/branding.ts +0 -14
  70. package/src/pi/viewer/tui/model.ts +0 -390
  71. package/src/pi/viewer/tui/openNote.ts +0 -11
  72. package/src/pi/viewer/tui/run.ts +0 -71
  73. package/src/pi/viewer/tui/surface/base.ts +0 -203
  74. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  75. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  76. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  77. package/src/pi/viewer/tui/surface/health.ts +0 -102
  78. package/src/pi/viewer/tui/theme.ts +0 -90
  79. package/src/pi/viewer/tui/workspace.ts +0 -53
  80. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -1,160 +0,0 @@
1
- /**
2
- * healthModel — staleness + link health, derived exclusively from the
3
- * GraphModel (weave-workspace §3).
4
- *
5
- * Zero new server/core fields: everything here is a projection of nodes,
6
- * edges, and `model.staleness`.
7
- */
8
-
9
- import type { GraphModel, GraphNode } from "../graph/model";
10
- import { deriveBacklinks } from "./links";
11
- import { listLabel } from "./tree";
12
- import type { SelectableRow } from "./types";
13
-
14
- export interface HealthRow extends SelectableRow {
15
- text: string;
16
- }
17
-
18
- export interface HealthSection {
19
- heading: string;
20
- rows: HealthRow[];
21
- }
22
-
23
- export interface HealthModel {
24
- sections: HealthSection[];
25
- }
26
-
27
- const HEALTH_LIST_CAP = 10;
28
-
29
- export function healthModel(model: GraphModel): HealthModel {
30
- const byId = new Map<string, GraphNode>();
31
- for (const n of model.nodes) byId.set(n.id, n);
32
- const sections: HealthSection[] = [];
33
-
34
- // Repository section
35
- const repo = byId.get("repository");
36
- if (repo) {
37
- const rows: HealthRow[] = [];
38
- const staleness = model.staleness;
39
- if (staleness) {
40
- rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
41
- for (let i = 0; i < staleness.reasons.length; i++) {
42
- rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
43
- }
44
- }
45
- if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
46
- if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
47
- let summarized = 0;
48
- for (const n of model.nodes) {
49
- if (n.kind !== "module") continue;
50
- const count = n.detail["summarized files"];
51
- if (count) summarized += Number(count);
52
- }
53
- if (summarized > 0) {
54
- rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
55
- }
56
- sections.push({ heading: "Repository", rows });
57
- }
58
-
59
- // Vault section
60
- const vault = byId.get("vault");
61
- if (vault) {
62
- const rows: HealthRow[] = [];
63
- const noteCount = Number(vault.detail.notes ?? "0");
64
- rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
65
- const prov = countProvenance(model.nodes);
66
- rows.push({
67
- id: "health:vault:provenance",
68
- text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
69
- });
70
- if (vault.detail.warning) {
71
- rows.push({ id: "health:vault:warning", text: vault.detail.warning });
72
- }
73
- sections.push({ heading: "Vault", rows });
74
- }
75
-
76
- // Link health
77
- const backlinks = deriveBacklinks(model.edges);
78
- const orphans: GraphNode[] = [];
79
- const dangling: { node: GraphNode; count: number }[] = [];
80
- for (const n of model.nodes) {
81
- if (n.kind !== "note") continue;
82
- if (!backlinks.has(n.id)) orphans.push(n);
83
- const dl = n.detail["dangling links"];
84
- if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
85
- }
86
- const degree = new Map<string, number>();
87
- for (const e of model.edges) {
88
- degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
89
- degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
90
- }
91
- const hubs = [...model.nodes]
92
- .map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
93
- .filter((x) => x.d > 0)
94
- .sort((a, b) => b.d - a.d)
95
- .slice(0, HEALTH_LIST_CAP);
96
-
97
- const linkRows: HealthRow[] = [];
98
- if (orphans.length > 0) {
99
- linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
100
- const shown = orphans.slice(0, HEALTH_LIST_CAP);
101
- for (let i = 0; i < shown.length; i++) {
102
- linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
103
- }
104
- if (orphans.length > HEALTH_LIST_CAP) {
105
- linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
106
- }
107
- } else {
108
- linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
109
- }
110
- if (dangling.length > 0) {
111
- linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
112
- const shown = dangling.slice(0, HEALTH_LIST_CAP);
113
- for (let i = 0; i < shown.length; i++) {
114
- linkRows.push({
115
- id: `health:link:dangling:${shown[i]!.node.id}`,
116
- text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
117
- target: shown[i]!.node.id,
118
- });
119
- }
120
- if (dangling.length > HEALTH_LIST_CAP) {
121
- linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
122
- }
123
- // How many are *repairable* is not knowable from the graph: resolution
124
- // needs note bodies and titles, which the model flattens away. Point at
125
- // the tool that does know rather than re-deriving a worse answer here.
126
- linkRows.push({ id: "health:link:dangling:repair", text: " repair: weave_note action=links (fix: true to apply)" });
127
- }
128
- if (hubs.length > 0) {
129
- linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
130
- for (let i = 0; i < hubs.length; i++) {
131
- linkRows.push({
132
- id: `health:link:hub:${hubs[i]!.n.id}`,
133
- text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
134
- target: hubs[i]!.n.id,
135
- });
136
- }
137
- }
138
- sections.push({ heading: "Link health", rows: linkRows });
139
-
140
- return { sections };
141
- }
142
-
143
- export interface ProvenanceCounts {
144
- total: number;
145
- human: number;
146
- agent: number;
147
- generated: number;
148
- structural: number;
149
- }
150
-
151
- export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
152
- const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
153
- for (const n of nodes) {
154
- if (n.provenance === "human") c.human++;
155
- else if (n.provenance === "agent") c.agent++;
156
- else if (n.provenance === "generated") c.generated++;
157
- else c.structural++;
158
- }
159
- return c;
160
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * BodyStore — shared note/.okf body cache (weave-view-tui-v2 §6, §9.1).
3
- *
4
- * Every pane (e.g. two Detail panes reading the same note) shares one fetch
5
- * per node id. A body load is kicked off once per id, in-flight loads are
6
- * deduped, and a refresh busts the cache so the next read re-fetches.
7
- *
8
- * The store is harness-free (takes injected loaders + an optional
9
- * onChange callback).
10
- */
11
-
12
- import type { ViewNote } from "../../../core/graph/current";
13
-
14
- /** The body loaders a BodyStore is bound to (bound to vault/cwd by run.ts). */
15
- export interface BodyLoaders {
16
- loadNote: (slug: string) => Promise<ViewNote | null>;
17
- loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
18
- }
19
-
20
- export interface BodyStoreOptions {
21
- loaders: BodyLoaders;
22
- /** Invoked (no args) when an async load resolves so the owner can re-render. */
23
- onChange?: () => void;
24
- }
25
-
26
- /**
27
- * A cache+dedup keyed by node id. `get` never throws and never double-loads.
28
- *
29
- * - `null` body = the node exists but has no loadable body (or it loaded to
30
- * null); the pane renders nothing for it.
31
- * - `undefined` = not yet requested (the pane shows a placeholder and asks the
32
- * store to load, mirroring v1's `bodyLinesFor`).
33
- * - `isLoading(id)` = an in-flight request is outstanding (pane shows "loading").
34
- */
35
- export class BodyStore {
36
- private readonly loaders: BodyLoaders;
37
- private readonly onChange: (() => void) | undefined;
38
- private cache = new Map<string, string | null>();
39
- private loading = new Set<string>();
40
-
41
- constructor(opts: BodyStoreOptions) {
42
- this.loaders = opts.loaders;
43
- this.onChange = opts.onChange;
44
- }
45
-
46
- /** True when the id is mid-load. */
47
- isLoading(id: string): boolean {
48
- return this.loading.has(id);
49
- }
50
-
51
- /** Cached body for the id, or undefined when not yet requested. */
52
- get(id: string): string | null | undefined {
53
- return this.cache.get(id);
54
- }
55
-
56
- /** True once a body has been requested for the id (cached or failed). */
57
- has(id: string): boolean {
58
- return this.cache.has(id);
59
- }
60
-
61
- /**
62
- * Kick off a load for `id` if not already requested/in-flight. Returns true
63
- * when a load was started (so the caller can show a placeholder). The load
64
- * uses `kind` (note/file) to pick the loader and `ref` (slug/rel path).
65
- */
66
- load(id: string, kind: "note" | "file", ref: string | undefined): boolean {
67
- if (ref === undefined) return false;
68
- if (this.cache.has(id) || this.loading.has(id)) return false;
69
- this.loading.add(id);
70
- if (kind === "note") {
71
- void this.loaders
72
- .loadNote(ref)
73
- .then((note) => this.finish(id, note?.body ?? null))
74
- .catch(() => this.finish(id, null));
75
- } else {
76
- void this.loaders
77
- .loadOkf(ref)
78
- .then((file) => this.finish(id, file?.body ?? null))
79
- .catch(() => this.finish(id, null));
80
- }
81
- return true;
82
- }
83
-
84
- /** Drop all cached bodies and in-flight markers (the `r` refresh). */
85
- clear(): void {
86
- this.cache.clear();
87
- this.loading.clear();
88
- }
89
-
90
- private finish(id: string, body: string | null): void {
91
- this.cache.set(id, body);
92
- this.loading.delete(id);
93
- this.onChange?.();
94
- }
95
- }
@@ -1,14 +0,0 @@
1
- import { visibleWidth } from "@earendil-works/pi-tui";
2
- import type { ThemeSlot } from "./theme";
3
-
4
- /** The TUI uses one terminal-safe mark. */
5
- export const MARK_GLYPH = "◈";
6
- export const WORDMARK = "weave view";
7
-
8
- export function renderMark(
9
- theme: { fg: (slot: ThemeSlot, text: string) => string },
10
- width: number,
11
- ): string {
12
- const mark = theme.fg("accent", MARK_GLYPH);
13
- return visibleWidth(mark) <= width ? mark : "";
14
- }
@@ -1,390 +0,0 @@
1
- /**
2
- * TUI-specific view-model for the weave-view explorer (weave-view-tui-design
3
- * §5, §10.1).
4
- *
5
- * The portable projections (`treeRows`, `detailModel`, `focusModel`,
6
- * `healthModel`, …) now live in `src/core/view` so the browser workspace can
7
- * share them (weave-workspace §3). What stays here is what is genuinely about
8
- * a terminal: the explorer's `ExplorerState`/`Action`/`reduce` state machine,
9
- * terminal-escape sanitising, and the theme glue.
10
- *
11
- * Everything moved is re-exported below, so TUI files keep importing from
12
- * `./model`.
13
- */
14
-
15
- import type { NoteSource } from "../../../core/types";
16
- import type { SelectableRow, TreeRow } from "../../../core/view";
17
- import { MAX_FILTER_LEN, PROVENANCE_CYCLE, provenanceStyle, type ProvenanceStyle } from "./theme";
18
-
19
- // ---------------------------------------------------------------------------
20
- // sanitizeTerminalText — the TUI's XSS analog (design §9.3)
21
- // ---------------------------------------------------------------------------
22
-
23
- /** Strip terminal-escape/control sequences from disk/user content before
24
- * styling or handing to Markdown: ESC (0x1b), CSI (0x9b), BEL (0x07), and
25
- * other C0 controls except newline (0x0a) and tab (0x09). */
26
- export function sanitizeTerminalText(s: string): string {
27
- // eslint-disable-next-line no-control-regex
28
- return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
29
- }
30
-
31
- // ---------------------------------------------------------------------------
32
- // ExplorerState + reduce — key→state transitions (design §6, §10.1)
33
- // ---------------------------------------------------------------------------
34
-
35
- export type Surface = "tree" | "detail" | "focus" | "health";
36
-
37
- export interface ExplorerState {
38
- surface: Surface;
39
- /** Inline search sub-mode (only meaningful on the tree surface). */
40
- searching: boolean;
41
- /** Selected node id (source of truth; the cursor index is derived). */
42
- selectedId: string | null;
43
- /** Node pinned for focus mode. */
44
- focusId: string | null;
45
- /** Node open in detail. */
46
- detailId: string | null;
47
- /** Tree expansion (when no filter is active). */
48
- expanded: Set<string>;
49
- showInternals: boolean;
50
- provFilter: NoteSource | null;
51
- query: string;
52
- helpOpen: boolean;
53
- refreshing: boolean;
54
- /** Bumped on every mutation so the render cache can key off it. */
55
- version: number;
56
- /** Scroll offset (top visible row index) for the current surface. */
57
- scrollOffset: number;
58
- }
59
-
60
- export function initialState(roots: readonly string[]): ExplorerState {
61
- const expanded = new Set<string>();
62
- for (const r of roots) expanded.add(r);
63
- const selectedId = roots[0] ?? null;
64
- return {
65
- surface: "tree",
66
- searching: false,
67
- selectedId,
68
- focusId: null,
69
- detailId: null,
70
- expanded,
71
- showInternals: false,
72
- provFilter: null,
73
- query: "",
74
- helpOpen: false,
75
- refreshing: false,
76
- version: 0,
77
- scrollOffset: 0,
78
- };
79
- }
80
-
81
- export type Action =
82
- | { type: "up" }
83
- | { type: "down" }
84
- | { type: "pageUp" }
85
- | { type: "pageDown" }
86
- | { type: "home" }
87
- | { type: "end" }
88
- | { type: "left" }
89
- | { type: "right" }
90
- | { type: "enter" }
91
- | { type: "searchStart" }
92
- | { type: "searchChar"; ch: string }
93
- | { type: "searchBackspace" }
94
- | { type: "searchCommit" }
95
- | { type: "cycleProvenance" }
96
- | { type: "toggleInternals" }
97
- | { type: "focus" }
98
- | { type: "focusExit" }
99
- | { type: "surfaceTree" }
100
- | { type: "surfaceHealth" }
101
- | { type: "refresh" }
102
- | { type: "refreshDone" }
103
- | { type: "toggleHelp" }
104
- | { type: "quit" }
105
- | { type: "esc" };
106
-
107
- /** Context reduce needs for movement: the selectable rows of the current
108
- * surface (in display order) and the viewport window size. */
109
- export interface ReduceCtx {
110
- rows: readonly SelectableRow[];
111
- window: number;
112
- }
113
-
114
- function bump(s: ExplorerState): ExplorerState {
115
- return { ...s, version: s.version + 1 };
116
- }
117
-
118
- function clampIndex(idx: number, len: number): number {
119
- if (len === 0) return -1;
120
- if (idx >= len) return len - 1;
121
- return idx;
122
- }
123
-
124
- function scrollForSelection(idx: number, window: number, prev: number): number {
125
- if (window <= 0) return 0;
126
- // keep selection visible: offset <= idx < offset + window
127
- if (idx < prev) return idx;
128
- if (idx >= prev + window) return idx - window + 1;
129
- return prev;
130
- }
131
-
132
- function provenanceCycleNext(current: NoteSource | null): NoteSource | null {
133
- const i = PROVENANCE_CYCLE.indexOf(current);
134
- const next = PROVENANCE_CYCLE[(i + 1) % PROVENANCE_CYCLE.length];
135
- return next ?? null;
136
- }
137
-
138
- /**
139
- * Apply an action to the explorer state. Pure: returns a new state.
140
- *
141
- * Movement uses `ctx.rows` (the current surface's selectable rows) and
142
- * `ctx.window` (viewport height) to clamp the cursor and adjust scroll.
143
- * The component builds `ctx.rows` from the surface's model function.
144
- */
145
- export function reduce(state: ExplorerState, action: Action, ctx: ReduceCtx = { rows: [], window: 24 }): ExplorerState {
146
- const rows = ctx.rows;
147
- const len = rows.length;
148
- const currentIdx = state.selectedId ? rows.findIndex((r) => r.id === state.selectedId || r.target === state.selectedId) : -1;
149
-
150
- switch (action.type) {
151
- case "up":
152
- case "down":
153
- case "pageUp":
154
- case "pageDown":
155
- case "home":
156
- case "end": {
157
- if (len === 0) return state;
158
- let nextIdx: number;
159
- if (action.type === "up") nextIdx = currentIdx <= 0 ? 0 : currentIdx - 1;
160
- else if (action.type === "down") nextIdx = currentIdx < 0 ? 0 : Math.min(currentIdx + 1, len - 1);
161
- else if (action.type === "pageUp") nextIdx = currentIdx < 0 ? 0 : Math.max(0, currentIdx - ctx.window);
162
- else if (action.type === "pageDown") nextIdx = currentIdx < 0 ? 0 : Math.min(len - 1, currentIdx + ctx.window);
163
- else if (action.type === "home") nextIdx = 0;
164
- else nextIdx = len - 1; // end
165
- nextIdx = clampIndex(nextIdx, len);
166
- const row = rows[nextIdx];
167
- if (!row) return state;
168
- const selectedId = row.target ?? row.id;
169
- const scrollOffset = scrollForSelection(nextIdx, ctx.window, state.scrollOffset);
170
- return bump({ ...state, selectedId, scrollOffset });
171
- }
172
-
173
- case "left":
174
- case "right": {
175
- if (state.surface !== "tree") return state;
176
- if (!state.selectedId) return state;
177
- // operate on the tree: find the row for selectedId
178
- const treeRowIdx = rows.findIndex((r) => r.id === state.selectedId);
179
- const treeRow = treeRowIdx >= 0 ? (rows[treeRowIdx] as TreeRow | undefined) : undefined;
180
- const isExpanded = state.expanded.has(state.selectedId);
181
- if (action.type === "right") {
182
- if (treeRow && treeRow.hasKids && !isExpanded) {
183
- const expanded = new Set(state.expanded).add(state.selectedId);
184
- return bump({ ...state, expanded });
185
- }
186
- if (treeRow && isExpanded) {
187
- // move to first child
188
- const child = rows[treeRowIdx + 1];
189
- if (child) return bump({ ...state, selectedId: child.id, scrollOffset: scrollForSelection(treeRowIdx + 1, ctx.window, state.scrollOffset) });
190
- }
191
- return state;
192
- }
193
- // left: collapse, or jump to parent
194
- if (treeRow && isExpanded) {
195
- const expanded = new Set(state.expanded);
196
- expanded.delete(state.selectedId);
197
- return bump({ ...state, expanded });
198
- }
199
- // jump to parent: the nearest preceding row with smaller depth
200
- for (let i = treeRowIdx - 1; i >= 0; i--) {
201
- const r = rows[i] as TreeRow | undefined;
202
- if (r && r.depth < (treeRow?.depth ?? 0)) {
203
- return bump({ ...state, selectedId: r.id, scrollOffset: scrollForSelection(i, ctx.window, state.scrollOffset) });
204
- }
205
- }
206
- return state;
207
- }
208
-
209
- case "enter": {
210
- if (state.searching) {
211
- // commit search: keep filter, exit search mode
212
- return bump({ ...state, searching: false, scrollOffset: 0 });
213
- }
214
- if (!state.selectedId) return state;
215
- const row = currentIdx >= 0 ? rows[currentIdx] : undefined;
216
- if (state.surface === "tree") {
217
- return bump({ ...state, surface: "detail", detailId: state.selectedId, scrollOffset: 0 });
218
- }
219
- if (state.surface === "detail") {
220
- // jump to the selected link/backlink target
221
- if (row && row.target) {
222
- return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
223
- }
224
- return state;
225
- }
226
- if (state.surface === "focus") {
227
- // re-center on the selected neighbor
228
- if (row && row.target) {
229
- return bump({ ...state, focusId: row.target, selectedId: row.target, scrollOffset: 0 });
230
- }
231
- return state;
232
- }
233
- if (state.surface === "health") {
234
- if (row && row.target) {
235
- return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
236
- }
237
- return state;
238
- }
239
- return state;
240
- }
241
-
242
- case "searchStart": {
243
- if (state.searching) return state;
244
- if (state.surface === "detail") return state;
245
- return bump({ ...state, searching: true });
246
- }
247
-
248
- case "searchChar": {
249
- if (!state.searching) return state;
250
- const q = (state.query + action.ch).slice(0, MAX_FILTER_LEN);
251
- return bump({ ...state, query: q, scrollOffset: 0 });
252
- }
253
-
254
- case "searchBackspace": {
255
- if (!state.searching) return state;
256
- return bump({ ...state, query: state.query.slice(0, -1), scrollOffset: 0 });
257
- }
258
-
259
- case "searchCommit": {
260
- if (!state.searching) return state;
261
- return bump({ ...state, searching: false, scrollOffset: 0 });
262
- }
263
-
264
- case "esc": {
265
- // precedence: search > surface-exit > quit
266
- if (state.searching) {
267
- return bump({ ...state, searching: false, query: "", scrollOffset: 0 });
268
- }
269
- if (state.surface === "detail") {
270
- return bump({ ...state, surface: "tree", scrollOffset: 0 });
271
- }
272
- if (state.surface === "focus") {
273
- return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
274
- }
275
- if (state.surface === "health") {
276
- return bump({ ...state, surface: "tree", scrollOffset: 0 });
277
- }
278
- // tree → quit (component resolves done(null))
279
- return bump({ ...state });
280
- }
281
-
282
- case "quit": {
283
- return bump({ ...state });
284
- }
285
-
286
- case "cycleProvenance": {
287
- if (state.searching) return state;
288
- return bump({ ...state, provFilter: provenanceCycleNext(state.provFilter), scrollOffset: 0 });
289
- }
290
-
291
- case "toggleInternals": {
292
- if (state.searching) return state;
293
- return bump({ ...state, showInternals: !state.showInternals, scrollOffset: 0 });
294
- }
295
-
296
- case "focus": {
297
- if (state.searching) return state;
298
- if (!state.selectedId) return state;
299
- return bump({ ...state, surface: "focus", focusId: state.selectedId, scrollOffset: 0 });
300
- }
301
-
302
- case "focusExit": {
303
- return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
304
- }
305
-
306
- case "surfaceTree": {
307
- if (state.searching) return state;
308
- return bump({ ...state, surface: "tree", scrollOffset: 0 });
309
- }
310
-
311
- case "surfaceHealth": {
312
- if (state.searching) return state;
313
- return bump({ ...state, surface: "health", scrollOffset: 0 });
314
- }
315
-
316
- case "refresh": {
317
- return bump({ ...state, refreshing: true });
318
- }
319
-
320
- case "refreshDone": {
321
- return bump({ ...state, refreshing: false });
322
- }
323
-
324
- case "toggleHelp": {
325
- if (state.searching) return state;
326
- return bump({ ...state, helpOpen: !state.helpOpen });
327
- }
328
- }
329
- // Exhaustive over Action; unreachable for valid inputs.
330
- return state;
331
- }
332
-
333
- /**
334
- * Merge explorer state across a refresh (design §6): keep the expanded set,
335
- * filter, surface, and selected node id. A selected/expanded id that no
336
- * longer exists drops out; selection falls back to the first root.
337
- */
338
- export function mergeAfterRefresh(prev: ExplorerState, nextRoots: readonly string[]): ExplorerState {
339
- let selectedId = prev.selectedId;
340
- if (selectedId !== null && !nextRoots.includes(selectedId)) {
341
- // selectedId may be a non-root; the component re-resolves it against the
342
- // new tree rows. Roots fallback only when it is null.
343
- }
344
- if (selectedId === null) selectedId = nextRoots[0] ?? null;
345
- return {
346
- ...prev,
347
- refreshing: false,
348
- selectedId,
349
- version: prev.version + 1,
350
- scrollOffset: 0,
351
- };
352
- }
353
-
354
- // ---------------------------------------------------------------------------
355
- // Re-exports — the portable view-models, kept importable from `./model` so no
356
- // TUI file needs to know they moved to src/core/view.
357
- // ---------------------------------------------------------------------------
358
-
359
- export {
360
- countProvenance,
361
- degreeOf,
362
- deriveBacklinks,
363
- detailModel,
364
- focusModel,
365
- focusNeighborhood,
366
- formatTreeMeta,
367
- graphRoots,
368
- healthModel,
369
- listLabel,
370
- relTime,
371
- treeEmptyHint,
372
- treeRows,
373
- type DetailLinkRow,
374
- type DetailMetaRow,
375
- type DetailModel,
376
- type FocusGroup,
377
- type FocusModel,
378
- type FocusRow,
379
- type HealthModel,
380
- type HealthRow,
381
- type HealthSection,
382
- type ProvenanceCounts,
383
- type SelectableRow,
384
- type TreeMeta,
385
- type TreeRow,
386
- type TreeState,
387
- } from "../../../core/view";
388
-
389
- // Re-export theme helpers some tests reach for.
390
- export { provenanceStyle, type ProvenanceStyle };
@@ -1,11 +0,0 @@
1
- /**
2
- * Compatibility re-export.
3
- *
4
- * The implementation moved to `src/core/openInEditor.ts` (weave-workspace
5
- * §5.3): the browser workspace's `POST /api/open` needs it and
6
- * `src/web/server/**` may not import `src/pi`. Keeping this module as a
7
- * one-line re-export means `run.ts` and every TUI test kept working without
8
- * an edit; there is nothing TUI-specific left to live here.
9
- */
10
-
11
- export { openNoteCommand, openNoteInEditor } from "../../../core/openInEditor";