pi-weave 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -16
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +30 -4
- 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/dist/app.js +183 -52
- package/src/web/client/graph/Graph.tsx +23 -2
- package/src/web/client/graph/column.model.ts +1 -1
- package/src/web/client/graph/graph.model.ts +69 -7
- package/src/web/client/graph/renderer.ts +19 -3
- package/src/web/client/note/Note.tsx +171 -20
- package/src/web/client/note/Replay.tsx +128 -0
- package/src/web/client/note/drafts.ts +50 -9
- package/src/web/client/note/note.model.ts +17 -0
- package/src/web/client/note/replay.model.ts +173 -0
- 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 +34 -10
- package/src/web/client/shell/keys.model.ts +20 -7
- package/src/web/client/shell/theme.ts +118 -1
- package/src/web/client/shell/workspace.css.ts +4 -0
- 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/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- 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,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";
|
package/src/pi/viewer/tui/run.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* runWeaveViewTui — wires the workspace explorer into a pi session
|
|
3
|
-
* (weave-view-tui-design §2, §4.1, §3.2).
|
|
4
|
-
*
|
|
5
|
-
* Guards (interactive terminal only), builds the graph from disk in the
|
|
6
|
-
* handler, then hands a ready workspace explorer to `ctx.ui.custom` so the
|
|
7
|
-
* explorer owns input for its whole lifetime. After `done(null)` resolves,
|
|
8
|
-
* the workspace status line is refreshed. Dependencies are injected so the
|
|
9
|
-
* component never touches a real terminal directly.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
14
|
-
buildCurrentGraph,
|
|
15
|
-
readNoteForView,
|
|
16
|
-
readOkfFileForView,
|
|
17
|
-
resolveVaultRoot,
|
|
18
|
-
} from "../../../core";
|
|
19
|
-
import { openNoteInEditor } from "./openNote";
|
|
20
|
-
import { WeaveWorkspace } from "./workspaceRoot";
|
|
21
|
-
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./surface/base";
|
|
22
|
-
import { getWorkspaceStatus, formatStatusLine } from "../../../core";
|
|
23
|
-
|
|
24
|
-
/** Open the in-terminal knowledge explorer. Returns when the explorer closes. */
|
|
25
|
-
export async function runWeaveViewTui(ctx: ExtensionCommandContext): Promise<void> {
|
|
26
|
-
// Guard: the TUI needs an interactive terminal (design §2).
|
|
27
|
-
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
28
|
-
ctx.ui.notify(
|
|
29
|
-
"pi-weave: '/weave-view' needs an interactive terminal to open the in-terminal explorer.",
|
|
30
|
-
"warning",
|
|
31
|
-
);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const cwd = ctx.cwd;
|
|
36
|
-
const vaultRoot = resolveVaultRoot();
|
|
37
|
-
const model = await buildCurrentGraph(cwd, vaultRoot);
|
|
38
|
-
|
|
39
|
-
const loaders: WeaveLoaders = {
|
|
40
|
-
loadNote: (slug) => readNoteForView(vaultRoot, slug),
|
|
41
|
-
loadOkf: (rel) => readOkfFileForView(cwd, rel),
|
|
42
|
-
openNote: (slug) => openNoteInEditor(vaultRoot, slug),
|
|
43
|
-
rebuild: () => buildCurrentGraph(cwd, vaultRoot),
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
await ctx.ui.custom(
|
|
47
|
-
(tui, theme, _keybindings, done) => {
|
|
48
|
-
const explorer = new WeaveWorkspace({
|
|
49
|
-
model,
|
|
50
|
-
theme: theme as unknown as WeaveTheme,
|
|
51
|
-
tui: tui as unknown as WeaveTui,
|
|
52
|
-
loaders,
|
|
53
|
-
done,
|
|
54
|
-
rows: tui.terminal.rows,
|
|
55
|
-
});
|
|
56
|
-
return explorer;
|
|
57
|
-
},
|
|
58
|
-
// Full-screen, not an overlay (design §4.1).
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
// Refresh the status line after close (a /weave-scan may have landed meanwhile).
|
|
62
|
-
const status = await getWorkspaceStatus(ctx.cwd);
|
|
63
|
-
let theme: { fg?: (slot: string, text: string) => string } | undefined;
|
|
64
|
-
try {
|
|
65
|
-
theme = (ctx.ui as unknown as { theme?: { fg?: (slot: string, text: string) => string } })?.theme;
|
|
66
|
-
} catch {
|
|
67
|
-
// ignore
|
|
68
|
-
}
|
|
69
|
-
const indicator = theme?.fg ? theme.fg("dim", "○") : "○";
|
|
70
|
-
ctx.ui.setStatus("weave", `${indicator} ${formatStatusLine(status)}`);
|
|
71
|
-
}
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* surface/base.ts — the shared Pane<Surface> wrapper (weave-view-tui-v2 §6).
|
|
3
|
-
*
|
|
4
|
-
* A **Surface** is a harness-free, self-owned component: it holds its own
|
|
5
|
-
* selection/scroll/filter/expansion state, renders its lines, and handles its
|
|
6
|
-
* own keys. Surfaces share the GraphModel, BodyStore, and loaders via a
|
|
7
|
-
* `SurfaceContext`. A **Pane** is a `Component` that hosts a Surface inside a
|
|
8
|
-
* bordered Box with a title and a focus ring (accent when active, dim `line`
|
|
9
|
-
* when inactive — conveyed by border + a leading `◆`, not color alone, per
|
|
10
|
-
* §10).
|
|
11
|
-
*
|
|
12
|
-
* The pane owns scrolling via the injected scroll/offset on the surface; the
|
|
13
|
-
* workspace root routes input to the single active pane's surface.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
17
|
-
import type { GraphModel } from "../../../../core/graph/model";
|
|
18
|
-
import { BodyStore } from "../bodyStore";
|
|
19
|
-
import type { ViewNote } from "../../../../core/graph/current";
|
|
20
|
-
import type { SurfaceKind } from "../workspace";
|
|
21
|
-
import type { ThemeSlot } from "../theme";
|
|
22
|
-
|
|
23
|
-
/** Minimal theme surface shared by the workspace root and its surfaces. */
|
|
24
|
-
export interface WeaveTheme {
|
|
25
|
-
fg(slot: ThemeSlot, text: string): string;
|
|
26
|
-
bg(slot: "selectedBg", text: string): string;
|
|
27
|
-
bold(text: string): string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Minimal TUI surface shared by the workspace root and its adapter. */
|
|
31
|
-
export interface WeaveTui {
|
|
32
|
-
requestRender(force?: boolean): void;
|
|
33
|
-
terminal: { rows: number; columns: number };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Injected readers/actions bound to the vault and repository by run.ts. */
|
|
37
|
-
export interface WeaveLoaders {
|
|
38
|
-
loadNote: (slug: string) => Promise<ViewNote | null>;
|
|
39
|
-
loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
|
|
40
|
-
openNote: (slug: string) => Promise<boolean>;
|
|
41
|
-
rebuild: () => Promise<GraphModel>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A cross-pane navigation request a surface emits (resolved by the root). */
|
|
45
|
-
export type SurfaceEvent =
|
|
46
|
-
| { type: "openDetail"; id: string }
|
|
47
|
-
| { type: "focusNode"; id: string }
|
|
48
|
-
| { type: "openEditor"; id: string };
|
|
49
|
-
|
|
50
|
-
/** A surface emits events (e.g. "open detail") that the workspace root resolves. */
|
|
51
|
-
export type SurfaceEventHandler = (event: SurfaceEvent) => void;
|
|
52
|
-
|
|
53
|
-
/** Shared, session-level deps every surface reads. */
|
|
54
|
-
export interface SurfaceContext {
|
|
55
|
-
model: GraphModel;
|
|
56
|
-
theme: WeaveTheme;
|
|
57
|
-
loaders: WeaveLoaders;
|
|
58
|
-
bodies: BodyStore;
|
|
59
|
-
/** Reference clock (epoch ms); defaults to Date.now at the root. */
|
|
60
|
-
now: () => number;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** A surface can also be constructed with a render callback (test seam). */
|
|
64
|
-
export interface SurfaceInit {
|
|
65
|
-
context: SurfaceContext;
|
|
66
|
-
/** Emit a cross-pane navigation event. */
|
|
67
|
-
onEvent?: SurfaceEventHandler;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** What a surface component must expose on top of the pi-tui Component contract. */
|
|
71
|
-
export interface Surface extends Component {
|
|
72
|
-
/** The surface kind this hosts. */
|
|
73
|
-
readonly kind: SurfaceKind;
|
|
74
|
-
/** Called by the workspace root when focus moves to/from this surface. */
|
|
75
|
-
setFocused(focused: boolean): void;
|
|
76
|
-
/** The pane title (surface name), shown in the border. */
|
|
77
|
-
title(): string;
|
|
78
|
-
/**
|
|
79
|
-
* Called after a refresh rebuilds the model, so a surface bound to a node
|
|
80
|
-
* that no longer exists can rebind to a survivor instead of showing
|
|
81
|
-
* "(not found)". Optional — surfaces with no node binding omit it.
|
|
82
|
-
*/
|
|
83
|
-
rebind?(model: GraphModel): void;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Minimal theme surface the pane chrome needs. */
|
|
87
|
-
export interface PaneTheme {
|
|
88
|
-
fg(slot: ThemeSlot, text: string): string;
|
|
89
|
-
bold(text: string): string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A selectable row within a rendered surface (mirrors model.SelectableRow). */
|
|
93
|
-
export interface PaneRow {
|
|
94
|
-
id: string;
|
|
95
|
-
/** Node id a row jumps to on enter (links/backlinks/neighbors). */
|
|
96
|
-
target?: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** The render result of a surface: lines plus the selectable rows. */
|
|
100
|
-
export interface SurfaceRender {
|
|
101
|
-
lines: string[];
|
|
102
|
-
rows: PaneRow[];
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** A generic selectable, windowed body builder used by the surfaces. */
|
|
106
|
-
export function windowLines(
|
|
107
|
-
lines: string[],
|
|
108
|
-
selLine: number,
|
|
109
|
-
scrollOffset: number,
|
|
110
|
-
window: number,
|
|
111
|
-
marker: (text: string) => string,
|
|
112
|
-
): string[] {
|
|
113
|
-
const win = Math.max(1, window);
|
|
114
|
-
let offset = scrollOffset;
|
|
115
|
-
if (selLine >= 0) {
|
|
116
|
-
if (selLine < offset) offset = selLine;
|
|
117
|
-
else if (selLine >= offset + win) offset = selLine - win + 1;
|
|
118
|
-
}
|
|
119
|
-
offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - win)));
|
|
120
|
-
const out: string[] = [];
|
|
121
|
-
const end = Math.min(lines.length, offset + win);
|
|
122
|
-
for (let i = offset; i < end; i++) {
|
|
123
|
-
const ln = lines[i]!;
|
|
124
|
-
out.push(i === selLine ? marker(ln) : ` ${ln}`);
|
|
125
|
-
}
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Pane — a bordered Box hosting a Surface. Renders a title + focus ring on
|
|
131
|
-
* the first line, then the surface's (windowed) lines. This is the v1 single
|
|
132
|
-
* pane chrome lifted into a reusable wrapper; the workspace root instantiates
|
|
133
|
-
* one Pane per pane node.
|
|
134
|
-
*/
|
|
135
|
-
export class Pane implements Component {
|
|
136
|
-
private readonly surface: Surface;
|
|
137
|
-
private readonly theme: PaneTheme;
|
|
138
|
-
private readonly borderFn: (slot: ThemeSlot, text: string) => string;
|
|
139
|
-
/** Current viewport height in rows (for windowing). */
|
|
140
|
-
rows = 24;
|
|
141
|
-
focused = false;
|
|
142
|
-
private cacheKey = "";
|
|
143
|
-
|
|
144
|
-
constructor(
|
|
145
|
-
surface: Surface,
|
|
146
|
-
theme: PaneTheme,
|
|
147
|
-
// Bind through the theme object: a bare `theme.fg` default would detach
|
|
148
|
-
// `this`, so the real pi theme's `fg` (which reads `this.fgColors.get(...)`)
|
|
149
|
-
// would crash with "Cannot read properties of undefined (reading 'get')".
|
|
150
|
-
borderFn: (slot: ThemeSlot, text: string) => string = (slot, text) => theme.fg(slot, text),
|
|
151
|
-
) {
|
|
152
|
-
this.surface = surface;
|
|
153
|
-
this.theme = theme;
|
|
154
|
-
this.borderFn = borderFn;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
get surfaceComponent(): Surface {
|
|
158
|
-
return this.surface;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
setFocused(focused: boolean): void {
|
|
162
|
-
this.focused = focused;
|
|
163
|
-
this.surface.setFocused(focused);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
invalidate(): void {
|
|
167
|
-
this.cacheKey = "";
|
|
168
|
-
this.surface.invalidate();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
render(width: number): string[] {
|
|
172
|
-
const inner = Math.max(2, width - 2);
|
|
173
|
-
// The surface already windows + selection-marks its own lines (each
|
|
174
|
-
// surface owns its scroll/selection state). The pane only wraps them in a
|
|
175
|
-
// border and PADS to fill its allocated height so the multi-pane view looks clean —
|
|
176
|
-
// it must NOT re-window (that double-applies the indent/marker and corrupts
|
|
177
|
-
// the layout). Each body line is sliced/padded to `inner` so borders align.
|
|
178
|
-
const surfaceLines = this.surface.render(inner);
|
|
179
|
-
const title = this.surface.title();
|
|
180
|
-
const active = this.focused;
|
|
181
|
-
const borderSlot = active ? "accent" : "dim";
|
|
182
|
-
const marker = active ? "◆ " : " ";
|
|
183
|
-
const titleText = `${marker}${title} `;
|
|
184
|
-
const top = `${this.borderFn(borderSlot, "┌")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┐")}`;
|
|
185
|
-
const titleVis = visibleWidth(titleText);
|
|
186
|
-
const titleLine = `${this.borderFn(borderSlot, "│")}${this.theme.bold(titleText)}${" ".repeat(Math.max(0, inner - titleVis))}${this.borderFn(borderSlot, "│")}`;
|
|
187
|
-
// top + title + bottom = 3 fixed lines; body fills the rest so both panes
|
|
188
|
-
// in a multi-pane render the same height.
|
|
189
|
-
const bodyRows = Math.max(0, this.rows - 3);
|
|
190
|
-
const out = [top, titleLine];
|
|
191
|
-
for (let i = 0; i < bodyRows; i++) {
|
|
192
|
-
const ln = surfaceLines[i] ?? "";
|
|
193
|
-
const slice = visibleWidth(ln) > inner ? truncateToWidth(ln, inner) : ln;
|
|
194
|
-
const pad = Math.max(0, inner - visibleWidth(slice));
|
|
195
|
-
out.push(`${this.borderFn(borderSlot, "│")}${slice}${" ".repeat(pad)}${this.borderFn(borderSlot, "│")}`);
|
|
196
|
-
}
|
|
197
|
-
const bottom = `${this.borderFn(borderSlot, "└")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┘")}`;
|
|
198
|
-
out.push(bottom);
|
|
199
|
-
return out;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export type { Component, SurfaceKind };
|