pi-weave 0.3.8 → 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 +22 -2
- 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,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* surface/health.ts — the Health surface as a Component (weave-view-tui-v2 §6).
|
|
3
|
-
*
|
|
4
|
-
* Renders `healthModel` (staleness + link health). Enter on a health row with
|
|
5
|
-
* a target opens that node in detail (emitted as a SurfaceEvent).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
-
import { healthModel, sanitizeTerminalText } from "../model";
|
|
10
|
-
import { windowLines, type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
|
|
11
|
-
|
|
12
|
-
export interface HealthSurfaceState {
|
|
13
|
-
selectedId: string | null;
|
|
14
|
-
scrollOffset: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class HealthSurface implements Surface {
|
|
18
|
-
readonly kind = "health" as const;
|
|
19
|
-
private readonly ctx;
|
|
20
|
-
private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
|
|
21
|
-
state: HealthSurfaceState;
|
|
22
|
-
paneRows = 24;
|
|
23
|
-
|
|
24
|
-
constructor(init: SurfaceInit) {
|
|
25
|
-
this.ctx = init.context;
|
|
26
|
-
this.onEvent = init.onEvent;
|
|
27
|
-
this.state = { selectedId: null, scrollOffset: 0 };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
title(): string {
|
|
31
|
-
return "Health";
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
setFocused(_focused: boolean): void {}
|
|
35
|
-
|
|
36
|
-
invalidate(): void {}
|
|
37
|
-
|
|
38
|
-
handleInput(data: string): void {
|
|
39
|
-
if (matchesKey(data, "up")) return this.move(-1);
|
|
40
|
-
if (matchesKey(data, "down")) return this.move(1);
|
|
41
|
-
if (matchesKey(data, "pageUp")) return this.movePage(-1);
|
|
42
|
-
if (matchesKey(data, "pageDown")) return this.movePage(1);
|
|
43
|
-
if (matchesKey(data, "home")) {
|
|
44
|
-
this.state = { ...this.state, selectedId: this.rows()[0]?.id ?? null, scrollOffset: 0 };
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (matchesKey(data, "end")) {
|
|
48
|
-
const r = this.rows();
|
|
49
|
-
const last = r[r.length - 1];
|
|
50
|
-
this.state = { ...this.state, selectedId: last?.id ?? null, scrollOffset: Math.max(0, r.length - this.paneRows) };
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (matchesKey(data, "enter")) {
|
|
54
|
-
const rows = this.rows();
|
|
55
|
-
const idx = rows.findIndex((r) => r.id === this.state.selectedId);
|
|
56
|
-
const row = idx >= 0 ? rows[idx] : undefined;
|
|
57
|
-
if (row && row.target) this.onEvent?.({ type: "openDetail", id: row.target });
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
private move(delta: 1 | -1): void {
|
|
63
|
-
const rows = this.rows();
|
|
64
|
-
if (rows.length === 0) return;
|
|
65
|
-
const idx = rows.findIndex((r) => r.id === this.state.selectedId);
|
|
66
|
-
const nextIdx = Math.max(0, Math.min(rows.length - 1, (idx < 0 ? -1 : idx) + delta));
|
|
67
|
-
const row = rows[nextIdx]!;
|
|
68
|
-
this.state = { ...this.state, selectedId: row.id };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private movePage(dir: 1 | -1): void {
|
|
72
|
-
const rows = this.rows();
|
|
73
|
-
const idx = rows.findIndex((r) => r.id === this.state.selectedId);
|
|
74
|
-
const target = idx < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, idx + dir * this.paneRows));
|
|
75
|
-
const row = rows[target];
|
|
76
|
-
if (row) this.state = { ...this.state, selectedId: row.id };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
private rows(): { id: string; target?: string }[] {
|
|
80
|
-
const h = healthModel(this.ctx.model);
|
|
81
|
-
return h.sections.flatMap((s) =>
|
|
82
|
-
s.rows.filter((r) => r.target).map((r): { id: string; target?: string } => ({ id: r.id, target: r.target! })),
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
render(width: number): string[] {
|
|
87
|
-
const t = this.ctx.theme;
|
|
88
|
-
const h = healthModel(this.ctx.model);
|
|
89
|
-
const lines: { text: string; id?: string }[] = [];
|
|
90
|
-
for (const s of h.sections) {
|
|
91
|
-
lines.push({ text: t.bold(s.heading) });
|
|
92
|
-
for (const r of s.rows) {
|
|
93
|
-
lines.push({ text: sanitizeTerminalText(r.text), id: r.id });
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
const selId = this.state.selectedId;
|
|
97
|
-
const selLine = selId ? lines.findIndex((l) => l.id === selId) : -1;
|
|
98
|
-
const marker = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
|
|
99
|
-
const windowed = windowLines(lines.map((l) => l.text), selLine, this.state.scrollOffset, Math.max(5, this.paneRows), marker);
|
|
100
|
-
return windowed.map((l) => truncateToWidth(l, width));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provenance & kind → glyph + theme color-slot maps for the weave-view TUI
|
|
3
|
-
* (weave-view-tui-design §7).
|
|
4
|
-
*
|
|
5
|
-
* Pure data: imports only core types. Theme slots are names only
|
|
6
|
-
* (`theme.fg(slot, …)` applies the actual color), so pi themes restyle
|
|
7
|
-
* everything — never hardcoded hex. Style-first per v2 P1: provenance is
|
|
8
|
-
* glyph + dimness, color is the weak secondary channel.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { NodeKind } from "../../../core/graph/model";
|
|
12
|
-
import type { NoteSource } from "../../../core/types";
|
|
13
|
-
|
|
14
|
-
/** Theme foreground color-slot names understood by pi's `Theme`. */
|
|
15
|
-
export type ThemeSlot =
|
|
16
|
-
| "accent"
|
|
17
|
-
| "success"
|
|
18
|
-
| "warning"
|
|
19
|
-
| "dim"
|
|
20
|
-
| "muted"
|
|
21
|
-
| "text";
|
|
22
|
-
|
|
23
|
-
/** Provenance rendering: glyph prefix, whole-row dimness, and theme slot. */
|
|
24
|
-
export interface ProvenanceStyle {
|
|
25
|
-
glyph: string;
|
|
26
|
-
/** When true, the whole row is rendered dim (generated knowledge). */
|
|
27
|
-
dim: boolean;
|
|
28
|
-
slot: ThemeSlot;
|
|
29
|
-
/** Word printed in the provenance cycle / header banner, e.g. "human". */
|
|
30
|
-
word: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The provenance cycle order for the `p` key: all → human → agent → generated → all. */
|
|
34
|
-
export const PROVENANCE_CYCLE: readonly (NoteSource | null)[] = [null, "human", "agent", "generated"];
|
|
35
|
-
|
|
36
|
-
export function provenanceStyle(prov: NoteSource | null): ProvenanceStyle {
|
|
37
|
-
switch (prov) {
|
|
38
|
-
case "human":
|
|
39
|
-
return { glyph: "●", dim: false, slot: "success", word: "human" };
|
|
40
|
-
case "agent":
|
|
41
|
-
return { glyph: "◐", dim: false, slot: "accent", word: "agent" };
|
|
42
|
-
case "generated":
|
|
43
|
-
return { glyph: "○", dim: true, slot: "dim", word: "generated" };
|
|
44
|
-
default:
|
|
45
|
-
// structural node — no provenance glyph; kind glyph leads.
|
|
46
|
-
return { glyph: "", dim: false, slot: "muted", word: "" };
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface KindStyle {
|
|
51
|
-
glyph: string;
|
|
52
|
-
slot: ThemeSlot;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Kind → glyph + slot. Notes defer to their provenance glyph (trust-first). */
|
|
56
|
-
export function kindStyle(kind: NodeKind): KindStyle {
|
|
57
|
-
switch (kind) {
|
|
58
|
-
case "vault":
|
|
59
|
-
return { glyph: "◆", slot: "accent" };
|
|
60
|
-
case "note":
|
|
61
|
-
// provenance glyph leads; kind slot unused for the marker.
|
|
62
|
-
return { glyph: "", slot: "text" };
|
|
63
|
-
case "repository":
|
|
64
|
-
return { glyph: "■", slot: "accent" };
|
|
65
|
-
case "module":
|
|
66
|
-
return { glyph: "▪", slot: "success" };
|
|
67
|
-
case "package":
|
|
68
|
-
return { glyph: "▲", slot: "success" };
|
|
69
|
-
case "entryPoint":
|
|
70
|
-
return { glyph: "▹", slot: "warning" };
|
|
71
|
-
case "gitState":
|
|
72
|
-
return { glyph: "⎇", slot: "warning" };
|
|
73
|
-
case "external":
|
|
74
|
-
return { glyph: "↗", slot: "warning" };
|
|
75
|
-
case "file":
|
|
76
|
-
return { glyph: "·", slot: "dim" };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Chevron shown for an expandable tree row. */
|
|
81
|
-
export function chevron(expanded: boolean, hasKids: boolean): string {
|
|
82
|
-
if (!hasKids) return " ";
|
|
83
|
-
return expanded ? "▾" : "▸";
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Selection gutter marker. */
|
|
87
|
-
export const SELECTION_MARKER = "›";
|
|
88
|
-
|
|
89
|
-
/** Maximum length of the inline search/filter string (design §9.4). */
|
|
90
|
-
export const MAX_FILTER_LEN = 200;
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { GraphModel } from "../../../core/graph/model";
|
|
2
|
-
import { graphRoots } from "./model";
|
|
3
|
-
|
|
4
|
-
export type SurfaceKind = "explore" | "detail" | "focus" | "health";
|
|
5
|
-
|
|
6
|
-
export interface PaneNode {
|
|
7
|
-
id: string;
|
|
8
|
-
surface: SurfaceKind;
|
|
9
|
-
nodeId: string | null;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface Workspace {
|
|
13
|
-
name: string;
|
|
14
|
-
panes: PaneNode[];
|
|
15
|
-
activePaneId: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function pane(surface: SurfaceKind, nodeId: string | null = null): PaneNode {
|
|
19
|
-
return { id: surface, surface, nodeId };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function workspacePanes(ws: Workspace): PaneNode[] {
|
|
23
|
-
return ws.panes;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function findPane(panes: PaneNode[], id: string): PaneNode | null {
|
|
27
|
-
return panes.find((item) => item.id === id) ?? null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function focusNext(ws: Workspace, dir: 1 | -1): Workspace {
|
|
31
|
-
if (ws.panes.length <= 1) return ws;
|
|
32
|
-
const index = ws.panes.findIndex((item) => item.id === ws.activePaneId);
|
|
33
|
-
const next = ws.panes[(index + dir + ws.panes.length) % ws.panes.length];
|
|
34
|
-
return next ? { ...ws, activePaneId: next.id } : ws;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** The only layout: four equal-width read-only surfaces. */
|
|
38
|
-
export function defaultWorkspace(model: GraphModel): Workspace {
|
|
39
|
-
const panes = [
|
|
40
|
-
pane("explore", graphRoots(model)[0] ?? null),
|
|
41
|
-
pane("detail"),
|
|
42
|
-
pane("focus"),
|
|
43
|
-
pane("health"),
|
|
44
|
-
];
|
|
45
|
-
return { name: "Explore", panes, activePaneId: panes[0]!.id };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Hide inactive panes in a narrow terminal. */
|
|
49
|
-
export function collapseForWidth(ws: Workspace, width: number): Workspace {
|
|
50
|
-
if (width >= 80) return ws;
|
|
51
|
-
const active = findPane(ws.panes, ws.activePaneId);
|
|
52
|
-
return active ? { ...ws, panes: [active] } : ws;
|
|
53
|
-
}
|
|
@@ -1,389 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* workspaceRoot.ts — the v2 workspace ROOT component (weave-view-tui-v2 §4, §7).
|
|
3
|
-
*
|
|
4
|
-
* Sits above the pure fixed `workspace.ts` model and the `surface/*`
|
|
5
|
-
* components. It composes panes from a `Workspace` value into a real pi-tui
|
|
6
|
-
* tree (HStack/Pane), owns the single input listener, routes keys and
|
|
7
|
-
* 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 and is the path wired in run.ts.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { HStack, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
15
|
-
import { BodyStore } from "./bodyStore";
|
|
16
|
-
import { renderMark } from "./branding";
|
|
17
|
-
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./surface/base";
|
|
18
|
-
import { countProvenance } from "./model";
|
|
19
|
-
import type { GraphModel, GraphNode } from "../../../core/graph/model";
|
|
20
|
-
import { Pane } from "./surface/base";
|
|
21
|
-
import type { Surface, SurfaceEvent, SurfaceContext } from "./surface/base";
|
|
22
|
-
import { ExploreSurface } from "./surface/explore";
|
|
23
|
-
import { bindDetail, DetailSurface } from "./surface/detail";
|
|
24
|
-
import { FocusSurface } from "./surface/focus";
|
|
25
|
-
import { HealthSurface } from "./surface/health";
|
|
26
|
-
import { collapseForWidth, defaultWorkspace, focusNext, workspacePanes, type PaneNode, type Workspace } from "./workspace";
|
|
27
|
-
|
|
28
|
-
export interface WeaveWorkspaceOptions {
|
|
29
|
-
model: GraphModel;
|
|
30
|
-
theme: WeaveTheme;
|
|
31
|
-
tui: WeaveTui;
|
|
32
|
-
loaders: WeaveLoaders;
|
|
33
|
-
done: (result: null) => void;
|
|
34
|
-
rows?: number;
|
|
35
|
-
now?: () => number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Decode the few root-level keys; all other input belongs to the active surface. */
|
|
39
|
-
export function decodeWorkspaceKey(data: string): string | null {
|
|
40
|
-
if (data === "?") return "help";
|
|
41
|
-
if (data === "q") return "quit";
|
|
42
|
-
if (data === "r") return "refresh";
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export class WeaveWorkspace implements Component {
|
|
47
|
-
model: GraphModel;
|
|
48
|
-
private readonly theme: WeaveTheme;
|
|
49
|
-
private readonly tui: WeaveTui;
|
|
50
|
-
private readonly loaders: WeaveLoaders;
|
|
51
|
-
private readonly done: (result: null) => void;
|
|
52
|
-
private readonly rows: number;
|
|
53
|
-
private readonly nowFn: () => number;
|
|
54
|
-
private readonly logo: string;
|
|
55
|
-
private readonly bodies: BodyStore;
|
|
56
|
-
private ctx: SurfaceContext;
|
|
57
|
-
/** paneId → surface instance. */
|
|
58
|
-
private panes = new Map<string, Surface>();
|
|
59
|
-
/** The fixed pane layout + focus. */
|
|
60
|
-
workspace: Workspace;
|
|
61
|
-
private helpOpen = false;
|
|
62
|
-
refreshing = false;
|
|
63
|
-
private quitted = false;
|
|
64
|
-
private renderCache = new Map<string, string[]>();
|
|
65
|
-
wantsKeyRelease = false;
|
|
66
|
-
|
|
67
|
-
constructor(opts: WeaveWorkspaceOptions) {
|
|
68
|
-
this.model = opts.model;
|
|
69
|
-
this.theme = opts.theme;
|
|
70
|
-
this.tui = opts.tui;
|
|
71
|
-
this.loaders = opts.loaders;
|
|
72
|
-
this.done = opts.done;
|
|
73
|
-
this.rows = opts.rows ?? 24;
|
|
74
|
-
this.nowFn = opts.now ?? Date.now;
|
|
75
|
-
this.logo = renderMark(opts.theme, 20);
|
|
76
|
-
this.bodies = new BodyStore({
|
|
77
|
-
loaders: opts.loaders,
|
|
78
|
-
onChange: () => this.invalidateAndRender(),
|
|
79
|
-
});
|
|
80
|
-
this.ctx = {
|
|
81
|
-
model: opts.model,
|
|
82
|
-
theme: opts.theme,
|
|
83
|
-
loaders: opts.loaders,
|
|
84
|
-
bodies: this.bodies,
|
|
85
|
-
now: this.nowFn,
|
|
86
|
-
};
|
|
87
|
-
this.workspace = defaultWorkspace(opts.model);
|
|
88
|
-
this.syncPanes();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
invalidate(): void {
|
|
92
|
-
this.renderCache.clear();
|
|
93
|
-
for (const s of this.panes.values()) s.invalidate();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
private invalidateAndRender(): void {
|
|
97
|
-
this.invalidate();
|
|
98
|
-
this.tui.requestRender();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
setModel(model: GraphModel): void {
|
|
102
|
-
this.model = model;
|
|
103
|
-
// Surfaces read the model through the shared context (this.ctx.model), not
|
|
104
|
-
// this.model — so a refresh that only swaps this.model leaves every pane
|
|
105
|
-
// showing the pre-refresh graph. Propagate to the context so `r` actually
|
|
106
|
-
// re-renders the body, then drop stale pane selections that no longer exist.
|
|
107
|
-
this.ctx.model = model;
|
|
108
|
-
for (const s of this.panes.values()) s.rebind?.(model);
|
|
109
|
-
this.bodies.clear();
|
|
110
|
-
this.refreshing = false;
|
|
111
|
-
this.invalidateAndRender();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Rebuild surface instances so every pane node in the tree has one. */
|
|
115
|
-
private syncPanes(): void {
|
|
116
|
-
const wanted = workspacePanes(this.workspace);
|
|
117
|
-
const next = new Map<string, Surface>();
|
|
118
|
-
for (const pn of wanted) {
|
|
119
|
-
let s = this.panes.get(pn.id);
|
|
120
|
-
if (!s) s = this.createSurface(pn);
|
|
121
|
-
next.set(pn.id, s);
|
|
122
|
-
}
|
|
123
|
-
this.panes = next;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private createSurface(pn: PaneNode): Surface {
|
|
127
|
-
const init = { context: this.ctx, onEvent: (e: SurfaceEvent) => this.onSurfaceEvent(e) };
|
|
128
|
-
switch (pn.surface) {
|
|
129
|
-
case "explore":
|
|
130
|
-
return new ExploreSurface(init);
|
|
131
|
-
case "detail":
|
|
132
|
-
return pn.nodeId ? bindDetail(init, pn.nodeId) : new DetailSurface(init);
|
|
133
|
-
case "focus": {
|
|
134
|
-
const f = new FocusSurface(init);
|
|
135
|
-
if (pn.nodeId) f.setFocus(pn.nodeId);
|
|
136
|
-
return f;
|
|
137
|
-
}
|
|
138
|
-
case "health":
|
|
139
|
-
return new HealthSurface(init);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
handleInput(data: string): void {
|
|
144
|
-
if (matchesKey(data, "tab")) return this.cycleFocus(1);
|
|
145
|
-
if (matchesKey(data, "shift+tab")) return this.cycleFocus(-1);
|
|
146
|
-
if (matchesKey(data, "escape")) {
|
|
147
|
-
// Esc precedence (mirrors v1): if the active pane is in a sub-mode
|
|
148
|
-
// (Explore search OR Detail goto-line), Esc clears it; otherwise Esc
|
|
149
|
-
// quits the explorer.
|
|
150
|
-
const active = this.activePane();
|
|
151
|
-
if (active && inSubMode(active)) {
|
|
152
|
-
active.handleInput?.(data);
|
|
153
|
-
this.invalidate();
|
|
154
|
-
this.tui.requestRender();
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
this.quit();
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const wsKey = decodeWorkspaceKey(data);
|
|
161
|
-
if (wsKey) {
|
|
162
|
-
this.applyWorkspaceKey(wsKey);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const active = this.activePane();
|
|
166
|
-
if (active?.handleInput) {
|
|
167
|
-
active.handleInput(data);
|
|
168
|
-
// CRITICAL: the surface updated its own selection/scroll state, but it
|
|
169
|
-
// has no `tui` and the workspace render cache is now stale. Invalidate
|
|
170
|
-
// (clears the workspace + per-surface caches) and request a fresh render
|
|
171
|
-
// — without this, arrow/enter/filter keys change state but the screen
|
|
172
|
-
// never updates (the "arrows don't navigate" bug).
|
|
173
|
-
this.invalidate();
|
|
174
|
-
this.tui.requestRender();
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
private quit(): void {
|
|
179
|
-
if (!this.quitted) {
|
|
180
|
-
this.quitted = true;
|
|
181
|
-
this.done(null);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private applyWorkspaceKey(key: string): void {
|
|
186
|
-
switch (key) {
|
|
187
|
-
case "quit":
|
|
188
|
-
this.quit();
|
|
189
|
-
return;
|
|
190
|
-
case "refresh":
|
|
191
|
-
if (this.refreshing) return;
|
|
192
|
-
this.refreshing = true;
|
|
193
|
-
this.invalidateAndRender();
|
|
194
|
-
void this.loaders
|
|
195
|
-
.rebuild()
|
|
196
|
-
.then((m) => this.setModel(m))
|
|
197
|
-
.catch(() => {
|
|
198
|
-
this.refreshing = false;
|
|
199
|
-
this.invalidateAndRender();
|
|
200
|
-
});
|
|
201
|
-
return;
|
|
202
|
-
case "help":
|
|
203
|
-
this.helpOpen = !this.helpOpen;
|
|
204
|
-
this.invalidateAndRender();
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
private cycleFocus(dir: 1 | -1): void {
|
|
210
|
-
this.workspace = focusNext(this.workspace, dir);
|
|
211
|
-
this.refreshFocusFlags();
|
|
212
|
-
this.invalidateAndRender();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private refreshFocusFlags(): void {
|
|
216
|
-
for (const pn of workspacePanes(this.workspace)) {
|
|
217
|
-
const s = this.panes.get(pn.id);
|
|
218
|
-
s?.setFocused(pn.id === this.workspace.activePaneId);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private setActive(paneId: string): void {
|
|
223
|
-
this.workspace = { ...this.workspace, activePaneId: paneId };
|
|
224
|
-
this.refreshFocusFlags();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private activePane(): Surface | undefined {
|
|
228
|
-
return this.panes.get(this.workspace.activePaneId);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private onSurfaceEvent(e: SurfaceEvent): void {
|
|
232
|
-
if (e.type === "openEditor") {
|
|
233
|
-
this.openInEditor(e.id);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (e.type === "openDetail") {
|
|
237
|
-
this.openDetail(e.id);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
if (e.type === "focusNode") {
|
|
241
|
-
this.openFocus(e.id);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
private openInEditor(id: string): void {
|
|
246
|
-
const node = this.model.nodes.find((n) => n.id === id);
|
|
247
|
-
if (!node || node.kind !== "note") return;
|
|
248
|
-
if (node.detail.slug) void this.loaders.openNote(node.detail.slug);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Open a selected node in the fixed Detail pane. */
|
|
252
|
-
private openDetail(id: string): void {
|
|
253
|
-
// (The active Detail surface already rebinds itself on enter, so no
|
|
254
|
-
// active-pane special case is needed here.)
|
|
255
|
-
const panes = workspacePanes(this.workspace);
|
|
256
|
-
const detail = panes.find((p) => p.surface === "detail");
|
|
257
|
-
if (!detail) return;
|
|
258
|
-
this.bindDetail(detail.id, id);
|
|
259
|
-
this.setActive(detail.id);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
private bindDetail(paneId: string, id: string): void {
|
|
263
|
-
const s = this.panes.get(paneId) as DetailSurface;
|
|
264
|
-
if (!s) return;
|
|
265
|
-
s.state = { nodeId: id, selectedId: id, scrollOffset: 0 };
|
|
266
|
-
this.requestBody(id);
|
|
267
|
-
this.invalidateAndRender();
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
private openFocus(id: string): void {
|
|
271
|
-
const active = this.activePane();
|
|
272
|
-
if (active && active.kind === "focus") {
|
|
273
|
-
(active as FocusSurface).setFocus(id);
|
|
274
|
-
this.invalidateAndRender();
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
const panes = workspacePanes(this.workspace);
|
|
278
|
-
const focus = panes.find((p) => p.surface === "focus");
|
|
279
|
-
if (focus) {
|
|
280
|
-
(this.panes.get(focus.id) as FocusSurface).setFocus(id);
|
|
281
|
-
this.setActive(focus.id);
|
|
282
|
-
this.invalidateAndRender();
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
private requestBody(id: string): void {
|
|
287
|
-
const node = this.model.nodes.find((n) => n.id === id);
|
|
288
|
-
if (!node) return;
|
|
289
|
-
if (node.kind === "note") this.bodies.load(id, "note", node.detail.slug);
|
|
290
|
-
else if (node.kind === "file") this.bodies.load(id, "file", node.detail.path);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
render(width: number): string[] {
|
|
294
|
-
const key = `${width}:${this.workspace.activePaneId}:${this.helpOpen}:${this.refreshing}`;
|
|
295
|
-
const cached = this.renderCache.get(key);
|
|
296
|
-
if (cached) return cached;
|
|
297
|
-
const out: string[] = [];
|
|
298
|
-
out.push(...this.renderHeader(width));
|
|
299
|
-
out.push(...this.renderBody(width));
|
|
300
|
-
if (width < 80) out.push(...this.renderTabBar(width));
|
|
301
|
-
out.push(...this.renderFooter(width));
|
|
302
|
-
const clamped = out.slice(0, Math.max(1, this.rows)).map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
|
|
303
|
-
this.renderCache.set(key, clamped);
|
|
304
|
-
return clamped;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private renderHeader(width: number): string[] {
|
|
308
|
-
const t = this.theme;
|
|
309
|
-
const counts = countProvenance(this.model.nodes);
|
|
310
|
-
const repo = this.model.nodes.find((n) => n.kind === "repository");
|
|
311
|
-
const repoState = repoStaleness(this.model, repo);
|
|
312
|
-
const repoPart = repo ? ` · repo ${repo.label}:${repoState}` : "";
|
|
313
|
-
const mark = this.logo;
|
|
314
|
-
const head = `${mark}${mark ? " " : ""}${t.bold("weave view")}`;
|
|
315
|
-
const fill = "─".repeat(Math.max(1, width - visibleWidth(head) - 2));
|
|
316
|
-
const line1 = `${head} ${fill} ${this.workspace.name} · ${workspacePanes(this.workspace).length} panes${repoPart}`;
|
|
317
|
-
const out: string[] = [truncateToWidth(line1, width)];
|
|
318
|
-
const banner = this.bannerText();
|
|
319
|
-
if (banner) out.push(t.fg("warning", truncateToWidth(banner, width)));
|
|
320
|
-
const countsLine = `notes ${counts.total} (● ${counts.human} / ◐ ${counts.agent} / ○ ${counts.generated})`;
|
|
321
|
-
out.push(truncateToWidth(t.fg("dim", countsLine), width));
|
|
322
|
-
return out;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private bannerText(): string | null {
|
|
326
|
-
return this.refreshing ? "refreshing…" : null;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private renderBody(width: number): string[] {
|
|
330
|
-
const effective = collapseForWidth(this.workspace, width);
|
|
331
|
-
const bodyRows = Math.max(1, this.rows - 4);
|
|
332
|
-
const stack = new HStack();
|
|
333
|
-
for (const pane of effective.panes) stack.addChild(this.buildPane(pane, bodyRows), { grow: 1 });
|
|
334
|
-
return stack.render(width);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private buildPane(node: PaneNode, rows: number): Component {
|
|
338
|
-
const s = this.panes.get(node.id);
|
|
339
|
-
if (!s) return { render: () => [], invalidate: () => {} };
|
|
340
|
-
(s as { paneRows?: number }).paneRows = Math.max(1, rows - 3);
|
|
341
|
-
const p = new Pane(s, this.theme);
|
|
342
|
-
p.rows = rows;
|
|
343
|
-
p.setFocused(node.id === this.workspace.activePaneId);
|
|
344
|
-
return p;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private renderTabBar(width: number): string[] {
|
|
348
|
-
const panes = workspacePanes(this.workspace);
|
|
349
|
-
const t = this.theme;
|
|
350
|
-
const parts = panes.map((p) => {
|
|
351
|
-
const active = p.id === this.workspace.activePaneId;
|
|
352
|
-
return active ? t.fg("accent", `[${p.surface}]`) : p.surface;
|
|
353
|
-
});
|
|
354
|
-
return [truncateToWidth(parts.join(" "), width)];
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
private renderFooter(width: number): string[] {
|
|
358
|
-
const t = this.theme;
|
|
359
|
-
if (this.helpOpen) {
|
|
360
|
-
const help = [
|
|
361
|
-
"Tab focus · ↑↓/jk move · ←→/hl expand · enter open · f focus · r refresh · ? help · q quit",
|
|
362
|
-
"↑↓/jk move · ←→/hl expand · enter open · / filter · p prov · f focus · r refresh · ? help · q quit",
|
|
363
|
-
];
|
|
364
|
-
return help.map((l) => t.fg("dim", truncateToWidth(l, width)));
|
|
365
|
-
}
|
|
366
|
-
const hint = "Tab focus · ↑↓/jk move · ←→/hl expand · enter open · f focus · r refresh · ? help · q quit";
|
|
367
|
-
return [t.fg("dim", truncateToWidth(hint, width))];
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** True when the active surface is in a sub-mode that Esc should clear instead
|
|
372
|
-
* of quitting — Explore's search (`state.searching`) or Detail's goto-line
|
|
373
|
-
* (`state.gotoBuf`). Surfaces own their own state; read defensively so a
|
|
374
|
-
* surface without either field simply returns false. */
|
|
375
|
-
function inSubMode(s: Surface | undefined): boolean {
|
|
376
|
-
const st = (s as { state?: { searching?: boolean; gotoBuf?: string | null } }).state;
|
|
377
|
-
return !!st && (st.searching === true || st.gotoBuf != null);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function repoStaleness(model: GraphModel, repo: GraphNode | undefined): string {
|
|
381
|
-
if (repo) {
|
|
382
|
-
const st = model.staleness;
|
|
383
|
-
if (st && "state" in st) return st.state;
|
|
384
|
-
return "fresh";
|
|
385
|
-
}
|
|
386
|
-
return "missing";
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
export type { Component };
|