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
package/README.md
CHANGED
|
@@ -50,21 +50,18 @@ attestation ([Publish workflow](.github/workflows/publish.yml)).
|
|
|
50
50
|
| Command | `/weave-scan` | build/refresh the repository index (light) |
|
|
51
51
|
| Command | `/weave-scan deep` | light index + model-summarized sidecars (opt-in, incremental) |
|
|
52
52
|
| Command | `/weave-scan-cancel` | stop an in-flight `/weave-scan deep` run |
|
|
53
|
-
| Command | `/weave-view` |
|
|
54
|
-
| Command | `/weave-view tui` | explore the same graph in-terminal (keyboard) |
|
|
53
|
+
| Command | `/weave-view` | explore the local knowledge graph in-terminal (keyboard) |
|
|
55
54
|
| Skill | `weave-notepad` | how the agent should take good notes |
|
|
56
55
|
| Skill | `weave-explore` | how the agent should explore repositories |
|
|
57
56
|
|
|
58
|
-
**`/weave-view`**
|
|
59
|
-
notes with trust provenance (solid = human, dashed = agent, dimmed = generated), wiki-link edges between notes, and the repository's
|
|
60
|
-
structure anchored to git state. It reads disk live on every refresh — never a stale cache. Zoom/scroll, drag to pan, click nodes to expand;
|
|
61
|
-
notes open in a rendered markdown side panel.
|
|
62
|
-
|
|
63
|
-
**`/weave-view tui`** explores the same knowledge graph in the terminal: an expandable containment tree (Explore), a 1-hop neighborhood
|
|
57
|
+
**`/weave-view`** explores the knowledge graph in the terminal: an expandable containment tree (Explore), a 1-hop neighborhood
|
|
64
58
|
(Focus), a selected-node detail view with note/`.okf` bodies, and a staleness + link health surface — all keyboard-driven and read-only.
|
|
65
|
-
|
|
59
|
+
It reads disk live (never a stale cache) over the `GraphModel` assembled from the vault + repository index; a pure, harness-free view-model
|
|
66
60
|
(`src/pi/viewer/tui/model.ts`) backs the `WeaveExplorer` component. See `docs/weave-view-tui-design.md`.
|
|
67
61
|
|
|
62
|
+
> The earlier in-browser graph viewer (`/weave-view` in a browser) has been retired and is being rebuilt on pixi.js; until then
|
|
63
|
+
> `/weave-view` opens the in-terminal explorer.
|
|
64
|
+
|
|
68
65
|
On session start, pi-weave detects the repository you're in, checks whether `.okf` exists and is fresh, and says so in the status footer — a
|
|
69
66
|
filled `●` marks weave as active (a deep scan spins it while running).
|
|
70
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
package/src/pi/index.ts
CHANGED
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
import { registerNoteTool } from "./tools/noteTool";
|
|
13
13
|
import { registerRepoTool } from "./tools/repoTool";
|
|
14
14
|
import { deepScanRepository, formatDeepScanResult } from "./summarize";
|
|
15
|
-
import { openInBrowser } from "./viewer/browser";
|
|
16
|
-
import { startViewer, type ViewerServer } from "./viewer/server";
|
|
17
15
|
import { runWeaveViewTui } from "./viewer/tui/run";
|
|
18
16
|
|
|
19
17
|
/**
|
|
@@ -29,9 +27,9 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
29
27
|
registerNoteTool(pi);
|
|
30
28
|
registerRepoTool(pi);
|
|
31
29
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
30
|
+
// /weave-view is the in-terminal explorer (the old browser viewer was
|
|
31
|
+
// retired for a pixi.js rewrite); it holds no session resources, so there
|
|
32
|
+
// is no per-session lifecycle to tear down.
|
|
35
33
|
let lastCtx: ExtensionContext | ExtensionCommandContext | null = null;
|
|
36
34
|
let lastStatusText: string | undefined = undefined;
|
|
37
35
|
let isActive = false;
|
|
@@ -60,9 +58,7 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
pi.on("session_shutdown", async () => {
|
|
63
|
-
|
|
64
|
-
viewer = null;
|
|
65
|
-
await server?.stop();
|
|
61
|
+
// The TUI explorer owns no session-scoped resources; nothing to stop.
|
|
66
62
|
});
|
|
67
63
|
|
|
68
64
|
pi.on("agent_start", async (_event, ctx) => {
|
|
@@ -101,20 +97,14 @@ export default function piWeave(pi: ExtensionAPI): void {
|
|
|
101
97
|
});
|
|
102
98
|
|
|
103
99
|
pi.registerCommand("weave-view", {
|
|
104
|
-
description: "Open the
|
|
100
|
+
description: "Open the in-terminal knowledge-graph explorer (vault + repository); reads from disk live",
|
|
105
101
|
handler: async (args, ctx) => {
|
|
106
102
|
const arg = args.trim().toLowerCase();
|
|
107
|
-
if (arg
|
|
108
|
-
await runWeaveViewTui(ctx);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (arg !== "") {
|
|
103
|
+
if (arg !== "" && arg !== "tui") {
|
|
112
104
|
ctx.ui.notify("usage: /weave-view [tui]", "warning");
|
|
113
105
|
return;
|
|
114
106
|
}
|
|
115
|
-
|
|
116
|
-
ctx.ui.notify(`pi-weave viewer: ${viewer.url} (reads from disk live; refresh the page any time)`, "info");
|
|
117
|
-
await openInBrowser(pi, ctx, viewer.url);
|
|
107
|
+
await runWeaveViewTui(ctx);
|
|
118
108
|
},
|
|
119
109
|
});
|
|
120
110
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BodyStore — shared note/.okf body cache (weave-view-tui-v2 §6, §9.1).
|
|
3
|
+
*
|
|
4
|
+
* The v1 WeaveExplorer cached bodies in private maps. v2 lifts that cache into
|
|
5
|
+
* a per-session store so every pane (e.g. two Detail panes reading the same
|
|
6
|
+
* note) shares one fetch per node id. Behavior is identical to v1's
|
|
7
|
+
* per-explorer cache: a body load is kicked off once per id, in-flight loads
|
|
8
|
+
* are deduped, and a refresh busts the cache so the next read re-fetches.
|
|
9
|
+
*
|
|
10
|
+
* The store is harness-free (takes injected loaders + an optional
|
|
11
|
+
* onChange callback), so it is unit-tested with fake loaders exactly like
|
|
12
|
+
* v1's body tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ViewNote } from "../../../core/graph/current";
|
|
16
|
+
|
|
17
|
+
/** The body loaders a BodyStore is bound to (bound to vault/cwd by run.ts). */
|
|
18
|
+
export interface BodyLoaders {
|
|
19
|
+
loadNote: (slug: string) => Promise<ViewNote | null>;
|
|
20
|
+
loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BodyStoreOptions {
|
|
24
|
+
loaders: BodyLoaders;
|
|
25
|
+
/** Invoked (no args) when an async load resolves so the owner can re-render. */
|
|
26
|
+
onChange?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A cache+dedup keyed by node id. `get` never throws and never double-loads.
|
|
31
|
+
*
|
|
32
|
+
* - `null` body = the node exists but has no loadable body (or it loaded to
|
|
33
|
+
* null); the pane renders nothing for it.
|
|
34
|
+
* - `undefined` = not yet requested (the pane shows a placeholder and asks the
|
|
35
|
+
* store to load, mirroring v1's `bodyLinesFor`).
|
|
36
|
+
* - `isLoading(id)` = an in-flight request is outstanding (pane shows "loading").
|
|
37
|
+
*/
|
|
38
|
+
export class BodyStore {
|
|
39
|
+
private readonly loaders: BodyLoaders;
|
|
40
|
+
private readonly onChange: (() => void) | undefined;
|
|
41
|
+
private cache = new Map<string, string | null>();
|
|
42
|
+
private loading = new Set<string>();
|
|
43
|
+
|
|
44
|
+
constructor(opts: BodyStoreOptions) {
|
|
45
|
+
this.loaders = opts.loaders;
|
|
46
|
+
this.onChange = opts.onChange;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the id is mid-load. */
|
|
50
|
+
isLoading(id: string): boolean {
|
|
51
|
+
return this.loading.has(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Cached body for the id, or undefined when not yet requested. */
|
|
55
|
+
get(id: string): string | null | undefined {
|
|
56
|
+
return this.cache.get(id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True once a body has been requested for the id (cached or failed). */
|
|
60
|
+
has(id: string): boolean {
|
|
61
|
+
return this.cache.has(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Kick off a load for `id` if not already requested/in-flight. Returns true
|
|
66
|
+
* when a load was started (so the caller can show a placeholder). The load
|
|
67
|
+
* uses `kind` (note/file) to pick the loader and `ref` (slug/rel path).
|
|
68
|
+
*/
|
|
69
|
+
load(id: string, kind: "note" | "file", ref: string | undefined): boolean {
|
|
70
|
+
if (ref === undefined) return false;
|
|
71
|
+
if (this.cache.has(id) || this.loading.has(id)) return false;
|
|
72
|
+
this.loading.add(id);
|
|
73
|
+
if (kind === "note") {
|
|
74
|
+
void this.loaders
|
|
75
|
+
.loadNote(ref)
|
|
76
|
+
.then((note) => this.finish(id, note?.body ?? null))
|
|
77
|
+
.catch(() => this.finish(id, null));
|
|
78
|
+
} else {
|
|
79
|
+
void this.loaders
|
|
80
|
+
.loadOkf(ref)
|
|
81
|
+
.then((file) => this.finish(id, file?.body ?? null))
|
|
82
|
+
.catch(() => this.finish(id, null));
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Drop all cached bodies and in-flight markers (the `r` refresh). */
|
|
88
|
+
clear(): void {
|
|
89
|
+
this.cache.clear();
|
|
90
|
+
this.loading.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private finish(id: string, body: string | null): void {
|
|
94
|
+
this.cache.set(id, body);
|
|
95
|
+
this.loading.delete(id);
|
|
96
|
+
this.onChange?.();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branding.ts — weave-view TUI logo & wordmark tiers (weave-view-tui-v2 §5).
|
|
3
|
+
*
|
|
4
|
+
* Renders the brand as a small, casual in-bar mark. Three tiers, auto-selected
|
|
5
|
+
* once per session from terminal capability and cached:
|
|
6
|
+
*
|
|
7
|
+
* 1. "kitty" — the real raster logo via pi-tui's `Image` component (a small
|
|
8
|
+
* ~2×2-cell favicon). The raw JPG is bundled and base64'd once.
|
|
9
|
+
* 2. "glyph" — a tiny curated Unicode glyph derived from the logo silhouette.
|
|
10
|
+
* 3. "plain" — `🧵` + wordmark, forced by PI_WEAVE_TUI_PLAIN.
|
|
11
|
+
*
|
|
12
|
+
* The line-art/unicode constants contain no ESC by construction (decision 1:
|
|
13
|
+
* the representation fallback is a curated constant, never generated at runtime).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { detectCapabilities, getCapabilities, Image, type Component } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { ThemeSlot } from "./theme";
|
|
18
|
+
|
|
19
|
+
/** Logo render tier. */
|
|
20
|
+
export type LogoTier = "kitty" | "glyph" | "plain";
|
|
21
|
+
|
|
22
|
+
/** A tiny Unicode mark derived from the logo silhouette (decision 1 fallback). */
|
|
23
|
+
export const MARK_GLYPH = "◈";
|
|
24
|
+
/** Absolute last-resort mark (also the forced plain tier). */
|
|
25
|
+
export const PLAIN_MARK = "🧵";
|
|
26
|
+
/** The wordmark shown after the mark. */
|
|
27
|
+
export const WORDMARK = "weave view";
|
|
28
|
+
|
|
29
|
+
/** Env var that forces the plain tier. */
|
|
30
|
+
export const PLAIN_ENV = "PI_WEAVE_TUI_PLAIN";
|
|
31
|
+
|
|
32
|
+
/** MIME of the bundled raster logo asset (downscaled JPEG of the logo). */
|
|
33
|
+
export const LOGO_MIME = "image/jpeg";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bundled downscaled raster copy of `docs/pi-weave-logo.jpg` (32×32, ~1.3 KB).
|
|
37
|
+
* Decision 1 (§15): ship a small raster copy and embed it via pi-tui's `Image`
|
|
38
|
+
* (Kitty graphics) as a small ~2×2-cell favicon in the header strip — a casual
|
|
39
|
+
* in-bar mark, not a hero splash. Stored base64 so the render path needs no
|
|
40
|
+
* fs/path resolution and the asset is trivially unit-testable.
|
|
41
|
+
*/
|
|
42
|
+
export const LOGO_B64 =
|
|
43
|
+
"/9j/4AAQSkZJRgABAQAASABIAAD/4QDORXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAIAAAExAAIAAAAVAAAAZodpAAQAAAABAAAAfAAAAAAAAABIAAAAAQAAAEgAAAABUGl4ZWxtYXRvciBQcm8gMi4xLjMAAAAEkAQAAgAAABQAAACyoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAgoAMABAAAAAEAAAAgAAAAADIwMjY6MDg6MjIgMTk6NTY6MjIA/+0AZFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAsHAFaAAMbJUccAgAAAgACHAI+AAgyMDI2MDgyMhwCPwALMTk1NjIyKzAwMDA4QklNBCUAAAAAABBB7D4+CxrdyaRylkhoEmIW/8AAEQgAIAAgAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAv/aAAwDAQACEQMRAD8A/E8Lk4rQsocXcD46SIf/AB4VDbpvYDua95+HfhtZtHn8QpBbXEdvKyM00iKW2RtKY4Vcjc+xGY45wAByQD62FwzqO17Luc0pWPC7yHNzM2Orsf1NUGXBxXu/xH8O/Y7Cy114baCG9m8oNBIrbCYlmCTKmdr7JEbHJwcH5gQPEZ02sR6UsThnTdr3XcIyuf/Q/GHT/JkcQzbQpO7Lccjtn3r0Dwv/AG1fMdH0e+e1e+kRTaEM4uZwTtBXhSRk7c8YOO/PlwJByK14dZvI7dbXcDEpJAKjv79ccetezQxfLHlZw1acn8L1PTPE+nanZpb2Wr6i99JBkw2gV4xbzSk8KuGA3BckgYKjGQMVzPjG70+ygh8LaXFZSQWshuGurdd8kkkqDKGY5YqgABUfKH3ECsW48U6vcWs1k02IbjbvUKBnb0AOMgdsA4xxXOE55oxGJ5lZDjRVk5atH//Z";
|
|
44
|
+
|
|
45
|
+
/** Terminal capability surface branding probes. */
|
|
46
|
+
export interface BrandCapabilities {
|
|
47
|
+
/** Kitty graphics protocol available. */
|
|
48
|
+
kitty: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Pure tier selection; exported for direct unit testing. */
|
|
52
|
+
export function logoTierFor(caps: BrandCapabilities, forcePlain: boolean): LogoTier {
|
|
53
|
+
if (forcePlain) return "plain";
|
|
54
|
+
if (caps.kitty) return "kitty";
|
|
55
|
+
return "glyph";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the environment (PI_WEAVE_TUI_PLAIN) to a plain flag. Exported as a
|
|
60
|
+
* small pure function so the env read is testable in isolation.
|
|
61
|
+
*/
|
|
62
|
+
export function plainEnv(env: Record<string, string | undefined> = process.env): boolean {
|
|
63
|
+
const v = env[PLAIN_ENV];
|
|
64
|
+
return v !== undefined && v !== "" && v !== "0";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Session-level probe cache (probed once, reused for the whole session §5.1).
|
|
68
|
+
let cachedTier: LogoTier | null = null;
|
|
69
|
+
let cachedCaps: BrandCapabilities | null = null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Return the session-cached capabilities (probed once). The probe runs at most
|
|
73
|
+
* once per process; subsequent calls reuse the cache. `getCaps` is injectable
|
|
74
|
+
* for tests (defaults to a live kitty probe). Never throws.
|
|
75
|
+
*/
|
|
76
|
+
export function probeGraphics(getCaps: () => BrandCapabilities): BrandCapabilities {
|
|
77
|
+
if (cachedCaps) return cachedCaps;
|
|
78
|
+
try {
|
|
79
|
+
cachedCaps = getCaps();
|
|
80
|
+
} catch {
|
|
81
|
+
cachedCaps = { kitty: false };
|
|
82
|
+
}
|
|
83
|
+
return cachedCaps;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the session logo tier, probing terminal capability once and caching
|
|
88
|
+
* for the process. `env` is injectable for tests.
|
|
89
|
+
*/
|
|
90
|
+
export function logoTier(env: Record<string, string | undefined> = process.env): LogoTier {
|
|
91
|
+
if (cachedTier) return cachedTier;
|
|
92
|
+
const caps = probeGraphics(() => getBrandCapabilities());
|
|
93
|
+
cachedTier = logoTierFor(caps, plainEnv(env));
|
|
94
|
+
return cachedTier;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reset the session probe cache (test seam). */
|
|
98
|
+
export function resetBrandCache(): void {
|
|
99
|
+
cachedTier = null;
|
|
100
|
+
cachedCaps = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A live kitty-capability probe backed by pi-tui's terminal capability query. */
|
|
104
|
+
export function getBrandCapabilities(): BrandCapabilities {
|
|
105
|
+
return { kitty: detectCapabilities().images === "kitty" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The mark rendered as a single header line (≤ width). This is the string the
|
|
110
|
+
* header strip / empty state embed. For the kitty tier the mark glyph is used
|
|
111
|
+
* in the text strip; the real raster is emitted via `logoImage()` when a kitty
|
|
112
|
+
* surface is available.
|
|
113
|
+
*/
|
|
114
|
+
export function renderMark(tier: LogoTier, theme: { fg: (slot: ThemeSlot, text: string) => string }, width: number): string {
|
|
115
|
+
let mark: string;
|
|
116
|
+
if (tier === "plain") {
|
|
117
|
+
mark = theme.fg("muted", PLAIN_MARK);
|
|
118
|
+
} else {
|
|
119
|
+
// kitty and glyph tiers share the curated glyph in the text strip.
|
|
120
|
+
mark = theme.fg("accent", MARK_GLYPH);
|
|
121
|
+
}
|
|
122
|
+
return mark.slice(0, Math.max(1, width));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A small pi-tui `Image` component for the kitty tier (decision 1 favicon).
|
|
127
|
+
* Takes the base64 JPG + mime; renders at ~2×2 cells. Returns a component that
|
|
128
|
+
* emits the Kitty sequence when the terminal supports it, else a text fallback.
|
|
129
|
+
*/
|
|
130
|
+
export function logoImage(
|
|
131
|
+
base64Data: string,
|
|
132
|
+
mimeType: string,
|
|
133
|
+
theme: { fg: (slot: ThemeSlot, text: string) => string },
|
|
134
|
+
): Component {
|
|
135
|
+
return new Image(base64Data, mimeType, { fallbackColor: (t) => theme.fg("text", t) }, {
|
|
136
|
+
maxWidthCells: 2,
|
|
137
|
+
maxHeightCells: 2,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the bundled kitty raster logo `Image` (decision 1 favicon). Returns a
|
|
143
|
+
* component only when the terminal supports the Kitty graphics protocol;
|
|
144
|
+
* `null` otherwise so the caller keeps the one-line glyph header. The render
|
|
145
|
+
* path splices this component's lines onto their own row(s) — never inlined
|
|
146
|
+
* into a styled text line. The base64 asset is a compile-time constant, so the
|
|
147
|
+
* only fallback needed is the Kitty gate (no fs/path reads at runtime).
|
|
148
|
+
*/
|
|
149
|
+
export function bundledLogoImage(
|
|
150
|
+
theme: { fg: (slot: ThemeSlot, text: string) => string },
|
|
151
|
+
): Component | null {
|
|
152
|
+
if (getCapabilities().images === "kitty") return logoImage(LOGO_B64, LOGO_MIME, theme);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
@@ -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
|
},
|