pi-weave 0.1.6 → 0.1.7
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 +6 -9
- package/package.json +1 -1
- package/src/pi/index.ts +7 -17
- package/src/pi/viewer/tui/bodyStore.ts +98 -0
- package/src/pi/viewer/tui/branding.ts +154 -0
- package/src/pi/viewer/tui/explorer.ts +16 -29
- package/src/pi/viewer/tui/openNote.ts +60 -0
- package/src/pi/viewer/tui/run.ts +13 -4
- package/src/pi/viewer/tui/surface/base.ts +182 -0
- package/src/pi/viewer/tui/surface/detail.ts +315 -0
- package/src/pi/viewer/tui/surface/explore.ts +204 -0
- package/src/pi/viewer/tui/surface/focus.ts +139 -0
- package/src/pi/viewer/tui/surface/health.ts +102 -0
- package/src/pi/viewer/tui/workspace.ts +381 -0
- package/src/pi/viewer/tui/workspaceRoot.ts +530 -0
- package/src/pi/viewer/browser.ts +0 -29
- package/src/pi/viewer/page.ts +0 -1316
- package/src/pi/viewer/server.ts +0 -229
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspaceRoot.ts — the v2 workspace ROOT component (weave-view-tui-v2 §4, §7).
|
|
3
|
+
*
|
|
4
|
+
* Sits above the pure `workspace.ts` split-tree model and the `surface/*`
|
|
5
|
+
* components. It composes panes from a `Workspace` value into a real pi-tui
|
|
6
|
+
* tree (VStack/HStack/Pane), owns the single input listener, routes keys
|
|
7
|
+
* (workspace §7.1 / pane §7.2) and cross-pane SurfaceEvents, renders the
|
|
8
|
+
* branded header + footer + narrow-mode tab bar (§9.2, decision 5), and
|
|
9
|
+
* applies responsive collapse.
|
|
10
|
+
*
|
|
11
|
+
* It is drivable with a fake tui/theme/loaders exactly like v1's WeaveExplorer.
|
|
12
|
+
* `WeaveExplorer` (v1 single-pane path) is kept intact for backward
|
|
13
|
+
* compatibility; the workspace root is the v2 multi-pane path wired in run.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HStack, matchesKey, truncateToWidth, visibleWidth, VStack, type Component } from "@earendil-works/pi-tui";
|
|
17
|
+
import { BodyStore } from "./bodyStore";
|
|
18
|
+
import { renderMark } from "./branding";
|
|
19
|
+
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./explorer";
|
|
20
|
+
import { countProvenance } from "./model";
|
|
21
|
+
import type { GraphModel, GraphNode } from "../../../core/graph/model";
|
|
22
|
+
import { Pane } from "./surface/base";
|
|
23
|
+
import type { Surface, SurfaceEvent, SurfaceContext } from "./surface/base";
|
|
24
|
+
import { ExploreSurface } from "./surface/explore";
|
|
25
|
+
import { bindDetail, DetailSurface } from "./surface/detail";
|
|
26
|
+
import { FocusSurface } from "./surface/focus";
|
|
27
|
+
import { HealthSurface } from "./surface/health";
|
|
28
|
+
import {
|
|
29
|
+
close,
|
|
30
|
+
collapseForWidth,
|
|
31
|
+
defaultWorkspace,
|
|
32
|
+
focusNext,
|
|
33
|
+
resize,
|
|
34
|
+
setPaneSurface,
|
|
35
|
+
split,
|
|
36
|
+
workspacePanes,
|
|
37
|
+
type PaneNode,
|
|
38
|
+
type Workspace,
|
|
39
|
+
type WorkspaceNode,
|
|
40
|
+
} from "./workspace";
|
|
41
|
+
|
|
42
|
+
export interface WeaveWorkspaceOptions {
|
|
43
|
+
model: GraphModel;
|
|
44
|
+
theme: WeaveTheme;
|
|
45
|
+
tui: WeaveTui;
|
|
46
|
+
loaders: WeaveLoaders;
|
|
47
|
+
done: (result: null) => void;
|
|
48
|
+
rows?: number;
|
|
49
|
+
now?: () => number;
|
|
50
|
+
/** Pre-rendered brand mark line (from branding.renderMark). */
|
|
51
|
+
logo?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Raster kitty logo (branding.bundledLogoImage) rendered on its own row(s)
|
|
54
|
+
* above the header text strip when Kitty graphics are available. `null`/
|
|
55
|
+
* absent keeps the one-line glyph header (decision 1 favicon, not a splash).
|
|
56
|
+
*/
|
|
57
|
+
logoImage?: Component | null;
|
|
58
|
+
/** Optional initial workspace (defaults to the Explore default). */
|
|
59
|
+
workspace?: Workspace;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Decode a workspace-level key (returns null when it's a pane key). */
|
|
63
|
+
export function decodeWorkspaceKey(data: string): string | null {
|
|
64
|
+
if (data === "\\") return "splitV";
|
|
65
|
+
if (data === "|") return "splitH";
|
|
66
|
+
if (data === "x") return "close";
|
|
67
|
+
if (data === "w") return "workspace";
|
|
68
|
+
if (data === "?") return "help";
|
|
69
|
+
if (data === "q") return "quit";
|
|
70
|
+
if (data === "r") return "refresh";
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class WeaveWorkspace implements Component {
|
|
75
|
+
model: GraphModel;
|
|
76
|
+
private readonly theme: WeaveTheme;
|
|
77
|
+
private readonly tui: WeaveTui;
|
|
78
|
+
private readonly loaders: WeaveLoaders;
|
|
79
|
+
private readonly done: (result: null) => void;
|
|
80
|
+
private readonly rows: number;
|
|
81
|
+
private readonly nowFn: () => number;
|
|
82
|
+
private readonly logo: string;
|
|
83
|
+
private readonly logoImage: Component | null;
|
|
84
|
+
private readonly bodies: BodyStore;
|
|
85
|
+
private ctx: SurfaceContext;
|
|
86
|
+
/** paneId → surface instance. */
|
|
87
|
+
private panes = new Map<string, Surface>();
|
|
88
|
+
/** The workspace split tree + focus. */
|
|
89
|
+
workspace: Workspace;
|
|
90
|
+
private helpOpen = false;
|
|
91
|
+
refreshing = false;
|
|
92
|
+
private quitted = false;
|
|
93
|
+
private renderCache = new Map<string, string[]>();
|
|
94
|
+
wantsKeyRelease = false;
|
|
95
|
+
|
|
96
|
+
constructor(opts: WeaveWorkspaceOptions) {
|
|
97
|
+
this.model = opts.model;
|
|
98
|
+
this.theme = opts.theme;
|
|
99
|
+
this.tui = opts.tui;
|
|
100
|
+
this.loaders = opts.loaders;
|
|
101
|
+
this.done = opts.done;
|
|
102
|
+
this.rows = opts.rows ?? 24;
|
|
103
|
+
this.nowFn = opts.now ?? Date.now;
|
|
104
|
+
this.logo = opts.logo ?? renderMark("glyph", opts.theme, 20);
|
|
105
|
+
this.logoImage = opts.logoImage ?? null;
|
|
106
|
+
this.bodies = new BodyStore({
|
|
107
|
+
loaders: opts.loaders,
|
|
108
|
+
onChange: () => this.invalidateAndRender(),
|
|
109
|
+
});
|
|
110
|
+
this.ctx = {
|
|
111
|
+
model: opts.model,
|
|
112
|
+
theme: opts.theme,
|
|
113
|
+
loaders: opts.loaders,
|
|
114
|
+
bodies: this.bodies,
|
|
115
|
+
now: this.nowFn,
|
|
116
|
+
};
|
|
117
|
+
this.workspace = opts.workspace ?? defaultWorkspace(opts.model);
|
|
118
|
+
this.syncPanes();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
invalidate(): void {
|
|
122
|
+
this.renderCache.clear();
|
|
123
|
+
for (const s of this.panes.values()) s.invalidate();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private invalidateAndRender(): void {
|
|
127
|
+
this.invalidate();
|
|
128
|
+
this.tui.requestRender();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setModel(model: GraphModel): void {
|
|
132
|
+
this.model = model;
|
|
133
|
+
// Surfaces read the model through the shared context (this.ctx.model), not
|
|
134
|
+
// this.model — so a refresh that only swaps this.model leaves every pane
|
|
135
|
+
// showing the pre-refresh graph. Propagate to the context so `r` actually
|
|
136
|
+
// re-renders the body, then drop stale pane selections that no longer exist.
|
|
137
|
+
this.ctx.model = model;
|
|
138
|
+
for (const s of this.panes.values()) s.rebind?.(model);
|
|
139
|
+
this.bodies.clear();
|
|
140
|
+
this.refreshing = false;
|
|
141
|
+
this.invalidateAndRender();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Rebuild surface instances so every pane node in the tree has one. */
|
|
145
|
+
private syncPanes(): void {
|
|
146
|
+
const wanted = workspacePanes(this.workspace);
|
|
147
|
+
const next = new Map<string, Surface>();
|
|
148
|
+
for (const pn of wanted) {
|
|
149
|
+
let s = this.panes.get(pn.id);
|
|
150
|
+
if (!s) s = this.createSurface(pn);
|
|
151
|
+
next.set(pn.id, s);
|
|
152
|
+
}
|
|
153
|
+
this.panes = next;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private createSurface(pn: PaneNode): Surface {
|
|
157
|
+
const init = { context: this.ctx, onEvent: (e: SurfaceEvent) => this.onSurfaceEvent(e) };
|
|
158
|
+
switch (pn.surface) {
|
|
159
|
+
case "explore":
|
|
160
|
+
return new ExploreSurface(init);
|
|
161
|
+
case "detail":
|
|
162
|
+
return pn.nodeId ? bindDetail(init, pn.nodeId) : new DetailSurface(init);
|
|
163
|
+
case "focus": {
|
|
164
|
+
const f = new FocusSurface(init);
|
|
165
|
+
if (pn.nodeId) f.setFocus(pn.nodeId);
|
|
166
|
+
return f;
|
|
167
|
+
}
|
|
168
|
+
case "health":
|
|
169
|
+
return new HealthSurface(init);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
handleInput(data: string): void {
|
|
174
|
+
if (matchesKey(data, "tab")) return this.cycleFocus(1);
|
|
175
|
+
if (matchesKey(data, "shift+tab")) return this.cycleFocus(-1);
|
|
176
|
+
if (matchesKey(data, "escape")) {
|
|
177
|
+
// Esc precedence (mirrors v1): if the active pane is in a sub-mode
|
|
178
|
+
// (Explore search OR Detail goto-line), Esc clears it; otherwise Esc
|
|
179
|
+
// quits the explorer.
|
|
180
|
+
const active = this.activePane();
|
|
181
|
+
if (active && inSubMode(active)) {
|
|
182
|
+
active.handleInput?.(data);
|
|
183
|
+
this.invalidate();
|
|
184
|
+
this.tui.requestRender();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.quit();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const wsKey = decodeWorkspaceKey(data);
|
|
191
|
+
if (wsKey) {
|
|
192
|
+
this.applyWorkspaceKey(wsKey);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (data === "e" || data === "d" || data === "h") {
|
|
196
|
+
this.swapSurface(data);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Ctrl+letters resize — only the four resize bytes are intercepted so
|
|
200
|
+
// enter (\r/0x0d), esc (0x1b) and other control keys reach the pane.
|
|
201
|
+
const ctrl = charCode(data);
|
|
202
|
+
if (ctrl !== undefined && (ctrl === 0x08 || ctrl === 0x0a || ctrl === 0x0b || ctrl === 0x0c)) {
|
|
203
|
+
this.applyResize(ctrl);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const active = this.activePane();
|
|
207
|
+
if (active?.handleInput) {
|
|
208
|
+
active.handleInput(data);
|
|
209
|
+
// CRITICAL: the surface updated its own selection/scroll state, but it
|
|
210
|
+
// has no `tui` and the workspace render cache is now stale. Invalidate
|
|
211
|
+
// (clears the workspace + per-surface caches) and request a fresh render
|
|
212
|
+
// — without this, arrow/enter/filter keys change state but the screen
|
|
213
|
+
// never updates (the "arrows don't navigate" bug).
|
|
214
|
+
this.invalidate();
|
|
215
|
+
this.tui.requestRender();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private quit(): void {
|
|
220
|
+
if (!this.quitted) {
|
|
221
|
+
this.quitted = true;
|
|
222
|
+
this.done(null);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private applyWorkspaceKey(key: string): void {
|
|
227
|
+
switch (key) {
|
|
228
|
+
case "splitV":
|
|
229
|
+
this.workspace = split(this.workspace, this.workspace.activePaneId, "vertical");
|
|
230
|
+
this.syncPanes();
|
|
231
|
+
this.invalidateAndRender();
|
|
232
|
+
return;
|
|
233
|
+
case "splitH":
|
|
234
|
+
this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal");
|
|
235
|
+
this.syncPanes();
|
|
236
|
+
this.invalidateAndRender();
|
|
237
|
+
return;
|
|
238
|
+
case "close":
|
|
239
|
+
this.workspace = close(this.workspace, this.workspace.activePaneId);
|
|
240
|
+
this.syncPanes();
|
|
241
|
+
this.invalidateAndRender();
|
|
242
|
+
return;
|
|
243
|
+
case "quit":
|
|
244
|
+
this.quit();
|
|
245
|
+
return;
|
|
246
|
+
case "refresh":
|
|
247
|
+
if (this.refreshing) return;
|
|
248
|
+
this.refreshing = true;
|
|
249
|
+
this.invalidateAndRender();
|
|
250
|
+
void this.loaders
|
|
251
|
+
.rebuild()
|
|
252
|
+
.then((m) => this.setModel(m))
|
|
253
|
+
.catch(() => {
|
|
254
|
+
this.refreshing = false;
|
|
255
|
+
this.invalidateAndRender();
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
case "workspace":
|
|
259
|
+
// TODO(M5): named-workspace switcher overlay; for now toggles help.
|
|
260
|
+
this.helpOpen = !this.helpOpen;
|
|
261
|
+
this.invalidateAndRender();
|
|
262
|
+
return;
|
|
263
|
+
case "help":
|
|
264
|
+
this.helpOpen = !this.helpOpen;
|
|
265
|
+
this.invalidateAndRender();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Ctrl+h/l adjust row weights; Ctrl+j/k adjust column weights. */
|
|
271
|
+
private applyResize(byte: number): void {
|
|
272
|
+
const active = this.workspace.activePaneId;
|
|
273
|
+
const d = 2;
|
|
274
|
+
if (byte === 0x08) this.workspace = resize(this.workspace, active, "row", -d); // Ctrl-h
|
|
275
|
+
else if (byte === 0x0c) this.workspace = resize(this.workspace, active, "row", d); // Ctrl-l
|
|
276
|
+
else if (byte === 0x0a) this.workspace = resize(this.workspace, active, "column", d); // Ctrl-j
|
|
277
|
+
else if (byte === 0x0b) this.workspace = resize(this.workspace, active, "column", -d); // Ctrl-k
|
|
278
|
+
this.syncPanes();
|
|
279
|
+
this.invalidateAndRender();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** e/d/h — swap the active pane's surface in place (f stays the pane focus key). */
|
|
283
|
+
private swapSurface(key: string): void {
|
|
284
|
+
const kind = key === "e" ? "explore" : key === "d" ? "detail" : "health";
|
|
285
|
+
const activeId = this.workspace.activePaneId;
|
|
286
|
+
this.workspace = setPaneSurface(this.workspace, activeId, kind);
|
|
287
|
+
const s = this.createSurface({ type: "pane", id: activeId, surface: kind, nodeId: null });
|
|
288
|
+
this.panes.set(activeId, s);
|
|
289
|
+
this.invalidateAndRender();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private cycleFocus(dir: 1 | -1): void {
|
|
293
|
+
this.workspace = focusNext(this.workspace, dir);
|
|
294
|
+
this.refreshFocusFlags();
|
|
295
|
+
this.invalidateAndRender();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private refreshFocusFlags(): void {
|
|
299
|
+
for (const pn of workspacePanes(this.workspace)) {
|
|
300
|
+
const s = this.panes.get(pn.id);
|
|
301
|
+
s?.setFocused(pn.id === this.workspace.activePaneId);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private setActive(paneId: string): void {
|
|
306
|
+
this.workspace = { ...this.workspace, activePaneId: paneId };
|
|
307
|
+
this.refreshFocusFlags();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private activePane(): Surface | undefined {
|
|
311
|
+
return this.panes.get(this.workspace.activePaneId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private onSurfaceEvent(e: SurfaceEvent): void {
|
|
315
|
+
if (e.type === "openEditor") {
|
|
316
|
+
this.openInEditor(e.id);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (e.type === "openDetail") {
|
|
320
|
+
this.openDetail(e.id);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (e.type === "focusNode") {
|
|
324
|
+
this.openFocus(e.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private openInEditor(id: string): void {
|
|
329
|
+
const node = this.model.nodes.find((n) => n.id === id);
|
|
330
|
+
if (!node || node.kind !== "note") return;
|
|
331
|
+
if (node.detail.slug) void this.loaders.openNote(node.detail.slug);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Obsidian "open in main": nearest Detail pane to the right, else split. */
|
|
335
|
+
private openDetail(id: string): void {
|
|
336
|
+
// (The active Detail surface already rebinds itself on enter, so no
|
|
337
|
+
// active-pane special case is needed here.)
|
|
338
|
+
const panes = workspacePanes(this.workspace);
|
|
339
|
+
const activeIdx = panes.findIndex((p) => p.id === this.workspace.activePaneId);
|
|
340
|
+
const toRight = panes.slice(activeIdx + 1).find((p) => p.surface === "detail");
|
|
341
|
+
if (toRight) {
|
|
342
|
+
this.bindDetail(toRight.id, id);
|
|
343
|
+
this.setActive(toRight.id);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// none to the right — split the active pane horizontally into a Detail
|
|
347
|
+
this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "detail");
|
|
348
|
+
this.syncPanes();
|
|
349
|
+
const detail = workspacePanes(this.workspace).find((p) => p.surface === "detail");
|
|
350
|
+
if (detail) {
|
|
351
|
+
this.bindDetail(detail.id, id);
|
|
352
|
+
this.setActive(detail.id);
|
|
353
|
+
}
|
|
354
|
+
this.invalidateAndRender();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private bindDetail(paneId: string, id: string): void {
|
|
358
|
+
const s = this.panes.get(paneId) as DetailSurface;
|
|
359
|
+
if (!s) return;
|
|
360
|
+
s.state = { nodeId: id, selectedId: id, scrollOffset: 0 };
|
|
361
|
+
this.requestBody(id);
|
|
362
|
+
this.invalidateAndRender();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private openFocus(id: string): void {
|
|
366
|
+
const active = this.activePane();
|
|
367
|
+
if (active && active.kind === "focus") {
|
|
368
|
+
(active as FocusSurface).setFocus(id);
|
|
369
|
+
this.invalidateAndRender();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const panes = workspacePanes(this.workspace);
|
|
373
|
+
const focus = panes.find((p) => p.surface === "focus");
|
|
374
|
+
if (focus) {
|
|
375
|
+
(this.panes.get(focus.id) as FocusSurface).setFocus(id);
|
|
376
|
+
this.setActive(focus.id);
|
|
377
|
+
this.invalidateAndRender();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "focus");
|
|
381
|
+
this.syncPanes();
|
|
382
|
+
const fp = workspacePanes(this.workspace).find((p) => p.surface === "focus");
|
|
383
|
+
if (fp) {
|
|
384
|
+
(this.panes.get(fp.id) as FocusSurface).setFocus(id);
|
|
385
|
+
this.setActive(fp.id);
|
|
386
|
+
}
|
|
387
|
+
this.invalidateAndRender();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private requestBody(id: string): void {
|
|
391
|
+
const node = this.model.nodes.find((n) => n.id === id);
|
|
392
|
+
if (!node) return;
|
|
393
|
+
if (node.kind === "note") this.bodies.load(id, "note", node.detail.slug);
|
|
394
|
+
else if (node.kind === "file") this.bodies.load(id, "file", node.detail.path);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
render(width: number): string[] {
|
|
398
|
+
const key = `${width}:${this.workspace.activePaneId}:${this.helpOpen}:${this.refreshing}`;
|
|
399
|
+
const cached = this.renderCache.get(key);
|
|
400
|
+
if (cached) return cached;
|
|
401
|
+
const out: string[] = [];
|
|
402
|
+
out.push(...this.renderHeader(width));
|
|
403
|
+
out.push(...this.renderBody(width));
|
|
404
|
+
if (width < 80) out.push(...this.renderTabBar(width));
|
|
405
|
+
out.push(...this.renderFooter(width));
|
|
406
|
+
const clamped = out.slice(0, Math.max(1, this.rows)).map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
|
|
407
|
+
this.renderCache.set(key, clamped);
|
|
408
|
+
return clamped;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private renderHeader(width: number): string[] {
|
|
412
|
+
const t = this.theme;
|
|
413
|
+
const counts = countProvenance(this.model.nodes);
|
|
414
|
+
const repo = this.model.nodes.find((n) => n.kind === "repository");
|
|
415
|
+
const repoState = repoStaleness(this.model, repo);
|
|
416
|
+
const repoPart = repo ? ` · repo ${repo.label}:${repoState}` : "";
|
|
417
|
+
// Kitty: the raster logo leads on its own row(s) and replaces the glyph in
|
|
418
|
+
// the text strip (decision 1 favicon). Otherwise the one-line glyph header
|
|
419
|
+
// stays intact.
|
|
420
|
+
let imageLines: string[] | null = null;
|
|
421
|
+
if (this.logoImage) {
|
|
422
|
+
try {
|
|
423
|
+
const lines = this.logoImage.render(width);
|
|
424
|
+
if (lines && lines.length) imageLines = lines;
|
|
425
|
+
} catch {
|
|
426
|
+
imageLines = null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const mark = imageLines ? "" : this.logo;
|
|
430
|
+
const head = `${mark}${mark ? " " : ""}${t.bold("weave view")}`;
|
|
431
|
+
const fill = "─".repeat(Math.max(1, width - visibleWidth(head) - 2));
|
|
432
|
+
const line1 = `${head} ${fill} ${this.workspace.name} · ${workspacePanes(this.workspace).length} panes${repoPart}`;
|
|
433
|
+
const out: string[] = [];
|
|
434
|
+
if (imageLines) out.push(...imageLines);
|
|
435
|
+
out.push(truncateToWidth(line1, width));
|
|
436
|
+
const banner = this.bannerText();
|
|
437
|
+
if (banner) out.push(t.fg("warning", truncateToWidth(banner, width)));
|
|
438
|
+
const countsLine = `notes ${counts.total} (● ${counts.human} / ◐ ${counts.agent} / ○ ${counts.generated})`;
|
|
439
|
+
out.push(truncateToWidth(t.fg("dim", countsLine), width));
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private bannerText(): string | null {
|
|
444
|
+
return this.refreshing ? "refreshing…" : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private renderBody(width: number): string[] {
|
|
448
|
+
const effective = collapseForWidth(this.workspace, width);
|
|
449
|
+
const bodyRows = Math.max(1, this.rows - 4);
|
|
450
|
+
const tree = this.buildSplit(effective.root, bodyRows);
|
|
451
|
+
return tree.render(width);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private buildSplit(node: WorkspaceNode, rows: number): Component {
|
|
455
|
+
if (node.type === "pane") {
|
|
456
|
+
const s = this.panes.get(node.id);
|
|
457
|
+
if (!s) return { render: () => [], invalidate: () => {} };
|
|
458
|
+
(s as { paneRows?: number }).paneRows = Math.max(1, rows - 3);
|
|
459
|
+
const p = new Pane(s, this.theme);
|
|
460
|
+
p.rows = rows;
|
|
461
|
+
p.setFocused(node.id === this.workspace.activePaneId);
|
|
462
|
+
return p;
|
|
463
|
+
}
|
|
464
|
+
const StackCtor = node.direction === "row" ? HStack : VStack;
|
|
465
|
+
const stack = new StackCtor();
|
|
466
|
+
// VStack stacks panes vertically, so each child's height must be its share
|
|
467
|
+
// of the available rows (by grow weight) — otherwise every child renders at
|
|
468
|
+
// full height, the stack overflows, and the render clamp leaves only the
|
|
469
|
+
// first pane visible (the "split hides the new pane" bug). HStack shares
|
|
470
|
+
// width, so its children keep the full height.
|
|
471
|
+
const vertical = node.direction !== "row";
|
|
472
|
+
const sizes = node.sizes.length ? node.sizes : node.children.map(() => 1);
|
|
473
|
+
const total = sizes.reduce((a, b) => a + b, 0) || node.children.length;
|
|
474
|
+
node.children.forEach((c, i) => {
|
|
475
|
+
const grow = sizes[i] ?? 1;
|
|
476
|
+
const childRows = vertical ? Math.max(1, Math.round((rows * grow) / total)) : rows;
|
|
477
|
+
stack.addChild(this.buildSplit(c, childRows), { grow });
|
|
478
|
+
});
|
|
479
|
+
return stack;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private renderTabBar(width: number): string[] {
|
|
483
|
+
const panes = workspacePanes(this.workspace);
|
|
484
|
+
const t = this.theme;
|
|
485
|
+
const parts = panes.map((p) => {
|
|
486
|
+
const active = p.id === this.workspace.activePaneId;
|
|
487
|
+
return active ? t.fg("accent", `[${p.surface}]`) : p.surface;
|
|
488
|
+
});
|
|
489
|
+
return [truncateToWidth(parts.join(" "), width)];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private renderFooter(width: number): string[] {
|
|
493
|
+
const t = this.theme;
|
|
494
|
+
if (this.helpOpen) {
|
|
495
|
+
const help = [
|
|
496
|
+
"Tab focus · \\ | split · Ctrl-hjkl resize · x close · e/d/f/h swap",
|
|
497
|
+
"↑↓/jk move · ←→/hl expand · enter open · / filter · p prov · f focus · r refresh · ? help · q quit",
|
|
498
|
+
];
|
|
499
|
+
return help.map((l) => t.fg("dim", truncateToWidth(l, width)));
|
|
500
|
+
}
|
|
501
|
+
const hint = "Tab focus · \\ split · Ctrl-hjkl resize · e/d/f/h pane · r refresh · ? help · q quit";
|
|
502
|
+
return [t.fg("dim", truncateToWidth(hint, width))];
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function charCode(data: string): number | undefined {
|
|
507
|
+
if (data.length !== 1) return undefined;
|
|
508
|
+
const c = data.charCodeAt(0);
|
|
509
|
+
return c >= 0 && c < 0x20 ? c : undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** True when the active surface is in a sub-mode that Esc should clear instead
|
|
513
|
+
* of quitting — Explore's search (`state.searching`) or Detail's goto-line
|
|
514
|
+
* (`state.gotoBuf`). Surfaces own their own state; read defensively so a
|
|
515
|
+
* surface without either field simply returns false. */
|
|
516
|
+
function inSubMode(s: Surface | undefined): boolean {
|
|
517
|
+
const st = (s as { state?: { searching?: boolean; gotoBuf?: string | null } }).state;
|
|
518
|
+
return !!st && (st.searching === true || st.gotoBuf != null);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function repoStaleness(model: GraphModel, repo: GraphNode | undefined): string {
|
|
522
|
+
if (repo) {
|
|
523
|
+
const st = model.staleness;
|
|
524
|
+
if (st && "state" in st) return st.state;
|
|
525
|
+
return "fresh";
|
|
526
|
+
}
|
|
527
|
+
return "missing";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export type { Component };
|
package/src/pi/viewer/browser.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser opening for /weave-view (docs/weave-view.md §4).
|
|
3
|
-
* Kept separate from the command so the per-platform command mapping is
|
|
4
|
-
* unit-testable without stubbing globals.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { platform } from "node:os";
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
|
|
10
|
-
/** The OS command used to open a URL in the default browser. */
|
|
11
|
-
export function browserCommand(
|
|
12
|
-
url: string,
|
|
13
|
-
os: NodeJS.Platform = platform(),
|
|
14
|
-
): { command: string; args: string[] } {
|
|
15
|
-
if (os === "darwin") return { command: "open", args: [url] };
|
|
16
|
-
if (os === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
|
|
17
|
-
return { command: "xdg-open", args: [url] };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Open the viewer URL in the user's browser — only in TUI mode with UI
|
|
22
|
-
* present; suppressible via PI_WEAVE_VIEW_NO_OPEN=1 (CI/headless/evals).
|
|
23
|
-
*/
|
|
24
|
-
export async function openInBrowser(pi: ExtensionAPI, ctx: ExtensionContext, url: string): Promise<void> {
|
|
25
|
-
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
26
|
-
if (process.env.PI_WEAVE_VIEW_NO_OPEN === "1") return;
|
|
27
|
-
const { command, args } = browserCommand(url);
|
|
28
|
-
await pi.exec(command, args, { timeout: 5_000 });
|
|
29
|
-
}
|