pi-weave 0.1.5 → 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 +30 -49
- package/package.json +3 -1
- package/skills/weave-explore/SKILL.md +18 -30
- package/skills/weave-notepad/SKILL.md +39 -36
- 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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { matchesKey, parseKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
16
|
+
import { BodyStore } from "./bodyStore";
|
|
16
17
|
|
|
17
18
|
import type { GraphModel, GraphNode, NodeKind } from "../../../core/graph/model";
|
|
18
19
|
import type { NoteSource } from "../../../core/types";
|
|
@@ -141,10 +142,8 @@ export class WeaveExplorer implements Component {
|
|
|
141
142
|
private readonly nowFn: () => number;
|
|
142
143
|
|
|
143
144
|
state: ExplorerState;
|
|
144
|
-
/**
|
|
145
|
-
private
|
|
146
|
-
/** In-flight body loads, keyed by node id. */
|
|
147
|
-
private bodyLoading = new Set<string>();
|
|
145
|
+
/** Shared note/okf body cache (dedup by node id, bust-on-refresh). */
|
|
146
|
+
private readonly bodies: BodyStore;
|
|
148
147
|
/** Render cache keyed by `${width}:${version}`. */
|
|
149
148
|
private renderCache = new Map<string, string[]>();
|
|
150
149
|
wantsKeyRelease = false;
|
|
@@ -157,14 +156,21 @@ export class WeaveExplorer implements Component {
|
|
|
157
156
|
this.done = opts.done;
|
|
158
157
|
this.rows = opts.rows ?? 24;
|
|
159
158
|
this.nowFn = opts.now ?? Date.now;
|
|
159
|
+
this.bodies = new BodyStore({
|
|
160
|
+
loaders: opts.loaders,
|
|
161
|
+
onChange: () => {
|
|
162
|
+
this.state = { ...this.state, version: this.state.version + 1 };
|
|
163
|
+
this.invalidate();
|
|
164
|
+
this.tui.requestRender();
|
|
165
|
+
},
|
|
166
|
+
});
|
|
160
167
|
this.state = initialState(graphRoots(this.model));
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
/** Replace the graph (used by the `r` refresh). Preserves selection by id. */
|
|
164
171
|
setModel(model: GraphModel): void {
|
|
165
172
|
this.model = model;
|
|
166
|
-
this.
|
|
167
|
-
this.bodyLoading.clear();
|
|
173
|
+
this.bodies.clear();
|
|
168
174
|
this.state = reduce(this.state, { type: "refreshDone" });
|
|
169
175
|
// Re-resolve selection: keep id if still present, else first root.
|
|
170
176
|
if (this.state.selectedId && !this.nodeExists(this.state.selectedId)) {
|
|
@@ -504,31 +510,12 @@ export class WeaveExplorer implements Component {
|
|
|
504
510
|
}
|
|
505
511
|
|
|
506
512
|
private maybeLoadBody(id: string): void {
|
|
507
|
-
if (this.bodyCache.has(id) || this.bodyLoading.has(id)) return;
|
|
508
513
|
const node = this.model.nodes.find((n) => n.id === id);
|
|
509
514
|
if (!node) return;
|
|
510
515
|
if (node.kind === "note") {
|
|
511
|
-
|
|
512
|
-
if (!slug) return;
|
|
513
|
-
this.bodyLoading.add(id);
|
|
514
|
-
void this.loaders.loadNote(slug).then((note) => {
|
|
515
|
-
this.bodyCache.set(id, note?.body ?? null);
|
|
516
|
-
this.bodyLoading.delete(id);
|
|
517
|
-
this.state = { ...this.state, version: this.state.version + 1 };
|
|
518
|
-
this.invalidate();
|
|
519
|
-
this.tui.requestRender();
|
|
520
|
-
});
|
|
516
|
+
this.bodies.load(id, "note", node.detail.slug);
|
|
521
517
|
} else if (node.kind === "file") {
|
|
522
|
-
|
|
523
|
-
if (!rel) return;
|
|
524
|
-
this.bodyLoading.add(id);
|
|
525
|
-
void this.loaders.loadOkf(rel).then((file) => {
|
|
526
|
-
this.bodyCache.set(id, file?.body ?? null);
|
|
527
|
-
this.bodyLoading.delete(id);
|
|
528
|
-
this.state = { ...this.state, version: this.state.version + 1 };
|
|
529
|
-
this.invalidate();
|
|
530
|
-
this.tui.requestRender();
|
|
531
|
-
});
|
|
518
|
+
this.bodies.load(id, "file", node.detail.path);
|
|
532
519
|
}
|
|
533
520
|
}
|
|
534
521
|
|
|
@@ -539,8 +526,8 @@ export class WeaveExplorer implements Component {
|
|
|
539
526
|
// never completes (maybeLoadBody skips non-note/file kinds).
|
|
540
527
|
const node = this.model.nodes.find((n) => n.id === id);
|
|
541
528
|
if (!node || (node.kind !== "note" && node.kind !== "file")) return [];
|
|
542
|
-
if (this.
|
|
543
|
-
const body = this.
|
|
529
|
+
if (this.bodies.isLoading(id)) return [this.theme.fg("dim", "(loading…)")];
|
|
530
|
+
const body = this.bodies.get(id);
|
|
544
531
|
if (body === undefined) {
|
|
545
532
|
// not yet requested — trigger a load (render is sync, so show placeholder)
|
|
546
533
|
queueMicrotask(() => this.maybeLoadBody(id));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openNote — open a vault note in the user's editor (weave-view tui).
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of the old browser viewer's `server.ts` when the in-browser
|
|
5
|
+
* viewer was retired in favour of a pixi.js rewrite; the TUI's `openNote`
|
|
6
|
+
* loader is the only remaining caller. The slug is validated against the
|
|
7
|
+
* vault (traversal-safe) before any shell-out, so an unsafe or missing
|
|
8
|
+
* note never reaches the editor.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { platform } from "node:os";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { getNote, resolveNotePath } from "../../../core";
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The OS command used to open a note file in the user's editor. Respects
|
|
20
|
+
* $EDITOR / $VISUAL (which may carry args, e.g. "code --wait"); falls back
|
|
21
|
+
* to the platform default opener. `env` and `os` are injectable so the
|
|
22
|
+
* mapping is unit-testable on any host without stubbing globals.
|
|
23
|
+
*/
|
|
24
|
+
export function openNoteCommand(
|
|
25
|
+
notePath: string,
|
|
26
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
27
|
+
os: NodeJS.Platform = platform(),
|
|
28
|
+
): { command: string; args: string[] } {
|
|
29
|
+
const editor = (env.EDITOR || env.VISUAL || "").trim();
|
|
30
|
+
if (editor) {
|
|
31
|
+
// editor is a trimmed non-empty string, so split+filter always yields a
|
|
32
|
+
// first segment — assert it rather than leave a dead `?? ""` branch.
|
|
33
|
+
const parts = editor.split(/\s+/).filter(Boolean);
|
|
34
|
+
const command = parts[0] as string;
|
|
35
|
+
return { command, args: [...parts.slice(1), notePath] };
|
|
36
|
+
}
|
|
37
|
+
if (os === "darwin") return { command: "open", args: [notePath] };
|
|
38
|
+
if (os === "win32") return { command: "cmd", args: ["/c", "start", "", notePath] };
|
|
39
|
+
return { command: "xdg-open", args: [notePath] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Open a note in the OS editor. The slug is validated against the vault
|
|
44
|
+
* (traversal-safe) before any shell-out; returns false when the note does
|
|
45
|
+
* not exist or the slug is unsafe.
|
|
46
|
+
*/
|
|
47
|
+
export async function openNoteInEditor(
|
|
48
|
+
vaultRoot: string,
|
|
49
|
+
slug: string,
|
|
50
|
+
openCommand: (notePath: string) => { command: string; args: string[] } = openNoteCommand,
|
|
51
|
+
): Promise<boolean> {
|
|
52
|
+
const path = resolveNotePath(vaultRoot, slug);
|
|
53
|
+
if (path === null) return false; // traversal-safe
|
|
54
|
+
const note = await getNote(vaultRoot, slug);
|
|
55
|
+
if (note === null) return false;
|
|
56
|
+
const { command, args } = openCommand(path);
|
|
57
|
+
if (!command) return false;
|
|
58
|
+
await execFileAsync(command, args);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
package/src/pi/viewer/tui/run.ts
CHANGED
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
resolveVaultRoot,
|
|
18
18
|
type GraphModel,
|
|
19
19
|
} from "../../../core";
|
|
20
|
-
import { openNoteInEditor } from "
|
|
21
|
-
import {
|
|
20
|
+
import { openNoteInEditor } from "./openNote";
|
|
21
|
+
import { bundledLogoImage, logoTier, renderMark } from "./branding";
|
|
22
|
+
import { WeaveWorkspace } from "./workspaceRoot";
|
|
23
|
+
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./explorer";
|
|
22
24
|
import { getWorkspaceStatus, formatStatusLine } from "../../../core";
|
|
23
25
|
|
|
24
26
|
/** Open the in-terminal knowledge explorer. Returns when the explorer closes. */
|
|
@@ -26,7 +28,7 @@ export async function runWeaveViewTui(ctx: ExtensionCommandContext): Promise<voi
|
|
|
26
28
|
// Guard: the TUI needs an interactive terminal (design §2).
|
|
27
29
|
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
28
30
|
ctx.ui.notify(
|
|
29
|
-
"pi-weave: '/weave-view
|
|
31
|
+
"pi-weave: '/weave-view' needs an interactive terminal to open the in-terminal explorer.",
|
|
30
32
|
"warning",
|
|
31
33
|
);
|
|
32
34
|
return;
|
|
@@ -45,13 +47,20 @@ export async function runWeaveViewTui(ctx: ExtensionCommandContext): Promise<voi
|
|
|
45
47
|
|
|
46
48
|
await ctx.ui.custom(
|
|
47
49
|
(tui, theme, _keybindings, done) => {
|
|
48
|
-
const
|
|
50
|
+
const tier = logoTier();
|
|
51
|
+
const logo = renderMark(tier, theme as unknown as WeaveTheme, 20);
|
|
52
|
+
// bundledLogoImage gates on Kitty support itself and returns null (glyph
|
|
53
|
+
// header) when unavailable.
|
|
54
|
+
const logoImage = bundledLogoImage(theme as unknown as WeaveTheme);
|
|
55
|
+
const explorer = new WeaveWorkspace({
|
|
49
56
|
model,
|
|
50
57
|
theme: theme as unknown as WeaveTheme,
|
|
51
58
|
tui: tui as unknown as WeaveTui,
|
|
52
59
|
loaders,
|
|
53
60
|
done,
|
|
54
61
|
rows: tui.terminal.rows,
|
|
62
|
+
logo,
|
|
63
|
+
logoImage,
|
|
55
64
|
});
|
|
56
65
|
return explorer;
|
|
57
66
|
},
|
|
@@ -0,0 +1,182 @@
|
|
|
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 { WeaveLoaders, WeaveTheme } from "../explorer";
|
|
20
|
+
import type { SurfaceKind } from "../workspace";
|
|
21
|
+
import type { ThemeSlot } from "../theme";
|
|
22
|
+
|
|
23
|
+
/** A cross-pane navigation request a surface emits (resolved by the root). */
|
|
24
|
+
export type SurfaceEvent =
|
|
25
|
+
| { type: "openDetail"; id: string }
|
|
26
|
+
| { type: "focusNode"; id: string }
|
|
27
|
+
| { type: "openEditor"; id: string };
|
|
28
|
+
|
|
29
|
+
/** A surface emits events (e.g. "open detail") that the workspace root resolves. */
|
|
30
|
+
export type SurfaceEventHandler = (event: SurfaceEvent) => void;
|
|
31
|
+
|
|
32
|
+
/** Shared, session-level deps every surface reads. */
|
|
33
|
+
export interface SurfaceContext {
|
|
34
|
+
model: GraphModel;
|
|
35
|
+
theme: WeaveTheme;
|
|
36
|
+
loaders: WeaveLoaders;
|
|
37
|
+
bodies: BodyStore;
|
|
38
|
+
/** Reference clock (epoch ms); defaults to Date.now at the root. */
|
|
39
|
+
now: () => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A surface can also be constructed with a render callback (test seam). */
|
|
43
|
+
export interface SurfaceInit {
|
|
44
|
+
context: SurfaceContext;
|
|
45
|
+
/** Emit a cross-pane navigation event. */
|
|
46
|
+
onEvent?: SurfaceEventHandler;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a surface component must expose on top of the pi-tui Component contract. */
|
|
50
|
+
export interface Surface extends Component {
|
|
51
|
+
/** The surface kind this hosts. */
|
|
52
|
+
readonly kind: SurfaceKind;
|
|
53
|
+
/** Called by the workspace root when focus moves to/from this surface. */
|
|
54
|
+
setFocused(focused: boolean): void;
|
|
55
|
+
/** The pane title (surface name), shown in the border. */
|
|
56
|
+
title(): string;
|
|
57
|
+
/**
|
|
58
|
+
* Called after a refresh rebuilds the model, so a surface bound to a node
|
|
59
|
+
* that no longer exists can rebind to a survivor instead of showing
|
|
60
|
+
* "(not found)". Optional — surfaces with no node binding omit it.
|
|
61
|
+
*/
|
|
62
|
+
rebind?(model: GraphModel): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Minimal theme surface the pane chrome needs. */
|
|
66
|
+
export interface PaneTheme {
|
|
67
|
+
fg(slot: ThemeSlot, text: string): string;
|
|
68
|
+
bold(text: string): string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A selectable row within a rendered surface (mirrors model.SelectableRow). */
|
|
72
|
+
export interface PaneRow {
|
|
73
|
+
id: string;
|
|
74
|
+
/** Node id a row jumps to on enter (links/backlinks/neighbors). */
|
|
75
|
+
target?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The render result of a surface: lines plus the selectable rows. */
|
|
79
|
+
export interface SurfaceRender {
|
|
80
|
+
lines: string[];
|
|
81
|
+
rows: PaneRow[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A generic selectable, windowed body builder used by the surfaces. */
|
|
85
|
+
export function windowLines(
|
|
86
|
+
lines: string[],
|
|
87
|
+
selLine: number,
|
|
88
|
+
scrollOffset: number,
|
|
89
|
+
window: number,
|
|
90
|
+
marker: (text: string) => string,
|
|
91
|
+
): string[] {
|
|
92
|
+
const win = Math.max(1, window);
|
|
93
|
+
let offset = scrollOffset;
|
|
94
|
+
if (selLine >= 0) {
|
|
95
|
+
if (selLine < offset) offset = selLine;
|
|
96
|
+
else if (selLine >= offset + win) offset = selLine - win + 1;
|
|
97
|
+
}
|
|
98
|
+
offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - win)));
|
|
99
|
+
const out: string[] = [];
|
|
100
|
+
const end = Math.min(lines.length, offset + win);
|
|
101
|
+
for (let i = offset; i < end; i++) {
|
|
102
|
+
const ln = lines[i]!;
|
|
103
|
+
out.push(i === selLine ? marker(ln) : ` ${ln}`);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pane — a bordered Box hosting a Surface. Renders a title + focus ring on
|
|
110
|
+
* the first line, then the surface's (windowed) lines. This is the v1 single
|
|
111
|
+
* pane chrome lifted into a reusable wrapper; the workspace root instantiates
|
|
112
|
+
* one Pane per pane node.
|
|
113
|
+
*/
|
|
114
|
+
export class Pane implements Component {
|
|
115
|
+
private readonly surface: Surface;
|
|
116
|
+
private readonly theme: PaneTheme;
|
|
117
|
+
private readonly borderFn: (slot: ThemeSlot, text: string) => string;
|
|
118
|
+
/** Current viewport height in rows (for windowing). */
|
|
119
|
+
rows = 24;
|
|
120
|
+
focused = false;
|
|
121
|
+
private cacheKey = "";
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
surface: Surface,
|
|
125
|
+
theme: PaneTheme,
|
|
126
|
+
// Bind through the theme object: a bare `theme.fg` default would detach
|
|
127
|
+
// `this`, so the real pi theme's `fg` (which reads `this.fgColors.get(...)`)
|
|
128
|
+
// would crash with "Cannot read properties of undefined (reading 'get')".
|
|
129
|
+
borderFn: (slot: ThemeSlot, text: string) => string = (slot, text) => theme.fg(slot, text),
|
|
130
|
+
) {
|
|
131
|
+
this.surface = surface;
|
|
132
|
+
this.theme = theme;
|
|
133
|
+
this.borderFn = borderFn;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get surfaceComponent(): Surface {
|
|
137
|
+
return this.surface;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setFocused(focused: boolean): void {
|
|
141
|
+
this.focused = focused;
|
|
142
|
+
this.surface.setFocused(focused);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
invalidate(): void {
|
|
146
|
+
this.cacheKey = "";
|
|
147
|
+
this.surface.invalidate();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
render(width: number): string[] {
|
|
151
|
+
const inner = Math.max(2, width - 2);
|
|
152
|
+
// The surface already windows + selection-marks its own lines (each
|
|
153
|
+
// surface owns its scroll/selection state). The pane only wraps them in a
|
|
154
|
+
// border and PADS to fill its allocated height so the split looks clean —
|
|
155
|
+
// it must NOT re-window (that double-applies the indent/marker and corrupts
|
|
156
|
+
// the layout). Each body line is sliced/padded to `inner` so borders align.
|
|
157
|
+
const surfaceLines = this.surface.render(inner);
|
|
158
|
+
const title = this.surface.title();
|
|
159
|
+
const active = this.focused;
|
|
160
|
+
const borderSlot = active ? "accent" : "dim";
|
|
161
|
+
const marker = active ? "◆ " : " ";
|
|
162
|
+
const titleText = `${marker}${title} `;
|
|
163
|
+
const top = `${this.borderFn(borderSlot, "┌")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┐")}`;
|
|
164
|
+
const titleVis = visibleWidth(titleText);
|
|
165
|
+
const titleLine = `${this.borderFn(borderSlot, "│")}${this.theme.bold(titleText)}${" ".repeat(Math.max(0, inner - titleVis))}${this.borderFn(borderSlot, "│")}`;
|
|
166
|
+
// top + title + bottom = 3 fixed lines; body fills the rest so both panes
|
|
167
|
+
// in a split render the same height.
|
|
168
|
+
const bodyRows = Math.max(0, this.rows - 3);
|
|
169
|
+
const out = [top, titleLine];
|
|
170
|
+
for (let i = 0; i < bodyRows; i++) {
|
|
171
|
+
const ln = surfaceLines[i] ?? "";
|
|
172
|
+
const slice = visibleWidth(ln) > inner ? truncateToWidth(ln, inner) : ln;
|
|
173
|
+
const pad = Math.max(0, inner - visibleWidth(slice));
|
|
174
|
+
out.push(`${this.borderFn(borderSlot, "│")}${slice}${" ".repeat(pad)}${this.borderFn(borderSlot, "│")}`);
|
|
175
|
+
}
|
|
176
|
+
const bottom = `${this.borderFn(borderSlot, "└")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┘")}`;
|
|
177
|
+
out.push(bottom);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type { Component, SurfaceKind };
|