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.
@@ -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
- /** Cached note/okf bodies keyed by node id; null = not yet loaded. */
145
- private bodyCache = new Map<string, string | null>();
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.bodyCache.clear();
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
- const slug = node.detail.slug;
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
- const rel = node.detail.path;
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.bodyLoading.has(id)) return [this.theme.fg("dim", "(loading…)")];
543
- const body = this.bodyCache.get(id);
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
+ }
@@ -17,8 +17,10 @@ import {
17
17
  resolveVaultRoot,
18
18
  type GraphModel,
19
19
  } from "../../../core";
20
- import { openNoteInEditor } from "../server";
21
- import { WeaveExplorer, type WeaveLoaders, type WeaveTheme, type WeaveTui } from "./explorer";
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 tui' needs an interactive terminal — run /weave-view for the browser viewer.",
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 explorer = new WeaveExplorer({
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 };