pi-weave 0.3.8 → 0.3.10
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 +21 -17
- 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/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- 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 +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- 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/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- 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
package/src/pi/viewer/tui/run.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* runWeaveViewTui — wires the workspace explorer into a pi session
|
|
3
|
-
* (weave-view-tui-design §2, §4.1, §3.2).
|
|
4
|
-
*
|
|
5
|
-
* Guards (interactive terminal only), builds the graph from disk in the
|
|
6
|
-
* handler, then hands a ready workspace explorer to `ctx.ui.custom` so the
|
|
7
|
-
* explorer owns input for its whole lifetime. After `done(null)` resolves,
|
|
8
|
-
* the workspace status line is refreshed. Dependencies are injected so the
|
|
9
|
-
* component never touches a real terminal directly.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
14
|
-
buildCurrentGraph,
|
|
15
|
-
readNoteForView,
|
|
16
|
-
readOkfFileForView,
|
|
17
|
-
resolveVaultRoot,
|
|
18
|
-
} from "../../../core";
|
|
19
|
-
import { openNoteInEditor } from "./openNote";
|
|
20
|
-
import { WeaveWorkspace } from "./workspaceRoot";
|
|
21
|
-
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./surface/base";
|
|
22
|
-
import { getWorkspaceStatus, formatStatusLine } from "../../../core";
|
|
23
|
-
|
|
24
|
-
/** Open the in-terminal knowledge explorer. Returns when the explorer closes. */
|
|
25
|
-
export async function runWeaveViewTui(ctx: ExtensionCommandContext): Promise<void> {
|
|
26
|
-
// Guard: the TUI needs an interactive terminal (design §2).
|
|
27
|
-
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
28
|
-
ctx.ui.notify(
|
|
29
|
-
"pi-weave: '/weave-view' needs an interactive terminal to open the in-terminal explorer.",
|
|
30
|
-
"warning",
|
|
31
|
-
);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const cwd = ctx.cwd;
|
|
36
|
-
const vaultRoot = resolveVaultRoot();
|
|
37
|
-
const model = await buildCurrentGraph(cwd, vaultRoot);
|
|
38
|
-
|
|
39
|
-
const loaders: WeaveLoaders = {
|
|
40
|
-
loadNote: (slug) => readNoteForView(vaultRoot, slug),
|
|
41
|
-
loadOkf: (rel) => readOkfFileForView(cwd, rel),
|
|
42
|
-
openNote: (slug) => openNoteInEditor(vaultRoot, slug),
|
|
43
|
-
rebuild: () => buildCurrentGraph(cwd, vaultRoot),
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
await ctx.ui.custom(
|
|
47
|
-
(tui, theme, _keybindings, done) => {
|
|
48
|
-
const explorer = new WeaveWorkspace({
|
|
49
|
-
model,
|
|
50
|
-
theme: theme as unknown as WeaveTheme,
|
|
51
|
-
tui: tui as unknown as WeaveTui,
|
|
52
|
-
loaders,
|
|
53
|
-
done,
|
|
54
|
-
rows: tui.terminal.rows,
|
|
55
|
-
});
|
|
56
|
-
return explorer;
|
|
57
|
-
},
|
|
58
|
-
// Full-screen, not an overlay (design §4.1).
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
// Refresh the status line after close (a /weave-scan may have landed meanwhile).
|
|
62
|
-
const status = await getWorkspaceStatus(ctx.cwd);
|
|
63
|
-
let theme: { fg?: (slot: string, text: string) => string } | undefined;
|
|
64
|
-
try {
|
|
65
|
-
theme = (ctx.ui as unknown as { theme?: { fg?: (slot: string, text: string) => string } })?.theme;
|
|
66
|
-
} catch {
|
|
67
|
-
// ignore
|
|
68
|
-
}
|
|
69
|
-
const indicator = theme?.fg ? theme.fg("dim", "○") : "○";
|
|
70
|
-
ctx.ui.setStatus("weave", `${indicator} ${formatStatusLine(status)}`);
|
|
71
|
-
}
|
|
@@ -1,203 +0,0 @@
|
|
|
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 { ViewNote } from "../../../../core/graph/current";
|
|
20
|
-
import type { SurfaceKind } from "../workspace";
|
|
21
|
-
import type { ThemeSlot } from "../theme";
|
|
22
|
-
|
|
23
|
-
/** Minimal theme surface shared by the workspace root and its surfaces. */
|
|
24
|
-
export interface WeaveTheme {
|
|
25
|
-
fg(slot: ThemeSlot, text: string): string;
|
|
26
|
-
bg(slot: "selectedBg", text: string): string;
|
|
27
|
-
bold(text: string): string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Minimal TUI surface shared by the workspace root and its adapter. */
|
|
31
|
-
export interface WeaveTui {
|
|
32
|
-
requestRender(force?: boolean): void;
|
|
33
|
-
terminal: { rows: number; columns: number };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Injected readers/actions bound to the vault and repository by run.ts. */
|
|
37
|
-
export interface WeaveLoaders {
|
|
38
|
-
loadNote: (slug: string) => Promise<ViewNote | null>;
|
|
39
|
-
loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
|
|
40
|
-
openNote: (slug: string) => Promise<boolean>;
|
|
41
|
-
rebuild: () => Promise<GraphModel>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A cross-pane navigation request a surface emits (resolved by the root). */
|
|
45
|
-
export type SurfaceEvent =
|
|
46
|
-
| { type: "openDetail"; id: string }
|
|
47
|
-
| { type: "focusNode"; id: string }
|
|
48
|
-
| { type: "openEditor"; id: string };
|
|
49
|
-
|
|
50
|
-
/** A surface emits events (e.g. "open detail") that the workspace root resolves. */
|
|
51
|
-
export type SurfaceEventHandler = (event: SurfaceEvent) => void;
|
|
52
|
-
|
|
53
|
-
/** Shared, session-level deps every surface reads. */
|
|
54
|
-
export interface SurfaceContext {
|
|
55
|
-
model: GraphModel;
|
|
56
|
-
theme: WeaveTheme;
|
|
57
|
-
loaders: WeaveLoaders;
|
|
58
|
-
bodies: BodyStore;
|
|
59
|
-
/** Reference clock (epoch ms); defaults to Date.now at the root. */
|
|
60
|
-
now: () => number;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** A surface can also be constructed with a render callback (test seam). */
|
|
64
|
-
export interface SurfaceInit {
|
|
65
|
-
context: SurfaceContext;
|
|
66
|
-
/** Emit a cross-pane navigation event. */
|
|
67
|
-
onEvent?: SurfaceEventHandler;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** What a surface component must expose on top of the pi-tui Component contract. */
|
|
71
|
-
export interface Surface extends Component {
|
|
72
|
-
/** The surface kind this hosts. */
|
|
73
|
-
readonly kind: SurfaceKind;
|
|
74
|
-
/** Called by the workspace root when focus moves to/from this surface. */
|
|
75
|
-
setFocused(focused: boolean): void;
|
|
76
|
-
/** The pane title (surface name), shown in the border. */
|
|
77
|
-
title(): string;
|
|
78
|
-
/**
|
|
79
|
-
* Called after a refresh rebuilds the model, so a surface bound to a node
|
|
80
|
-
* that no longer exists can rebind to a survivor instead of showing
|
|
81
|
-
* "(not found)". Optional — surfaces with no node binding omit it.
|
|
82
|
-
*/
|
|
83
|
-
rebind?(model: GraphModel): void;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Minimal theme surface the pane chrome needs. */
|
|
87
|
-
export interface PaneTheme {
|
|
88
|
-
fg(slot: ThemeSlot, text: string): string;
|
|
89
|
-
bold(text: string): string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A selectable row within a rendered surface (mirrors model.SelectableRow). */
|
|
93
|
-
export interface PaneRow {
|
|
94
|
-
id: string;
|
|
95
|
-
/** Node id a row jumps to on enter (links/backlinks/neighbors). */
|
|
96
|
-
target?: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** The render result of a surface: lines plus the selectable rows. */
|
|
100
|
-
export interface SurfaceRender {
|
|
101
|
-
lines: string[];
|
|
102
|
-
rows: PaneRow[];
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** A generic selectable, windowed body builder used by the surfaces. */
|
|
106
|
-
export function windowLines(
|
|
107
|
-
lines: string[],
|
|
108
|
-
selLine: number,
|
|
109
|
-
scrollOffset: number,
|
|
110
|
-
window: number,
|
|
111
|
-
marker: (text: string) => string,
|
|
112
|
-
): string[] {
|
|
113
|
-
const win = Math.max(1, window);
|
|
114
|
-
let offset = scrollOffset;
|
|
115
|
-
if (selLine >= 0) {
|
|
116
|
-
if (selLine < offset) offset = selLine;
|
|
117
|
-
else if (selLine >= offset + win) offset = selLine - win + 1;
|
|
118
|
-
}
|
|
119
|
-
offset = Math.max(0, Math.min(offset, Math.max(0, lines.length - win)));
|
|
120
|
-
const out: string[] = [];
|
|
121
|
-
const end = Math.min(lines.length, offset + win);
|
|
122
|
-
for (let i = offset; i < end; i++) {
|
|
123
|
-
const ln = lines[i]!;
|
|
124
|
-
out.push(i === selLine ? marker(ln) : ` ${ln}`);
|
|
125
|
-
}
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Pane — a bordered Box hosting a Surface. Renders a title + focus ring on
|
|
131
|
-
* the first line, then the surface's (windowed) lines. This is the v1 single
|
|
132
|
-
* pane chrome lifted into a reusable wrapper; the workspace root instantiates
|
|
133
|
-
* one Pane per pane node.
|
|
134
|
-
*/
|
|
135
|
-
export class Pane implements Component {
|
|
136
|
-
private readonly surface: Surface;
|
|
137
|
-
private readonly theme: PaneTheme;
|
|
138
|
-
private readonly borderFn: (slot: ThemeSlot, text: string) => string;
|
|
139
|
-
/** Current viewport height in rows (for windowing). */
|
|
140
|
-
rows = 24;
|
|
141
|
-
focused = false;
|
|
142
|
-
private cacheKey = "";
|
|
143
|
-
|
|
144
|
-
constructor(
|
|
145
|
-
surface: Surface,
|
|
146
|
-
theme: PaneTheme,
|
|
147
|
-
// Bind through the theme object: a bare `theme.fg` default would detach
|
|
148
|
-
// `this`, so the real pi theme's `fg` (which reads `this.fgColors.get(...)`)
|
|
149
|
-
// would crash with "Cannot read properties of undefined (reading 'get')".
|
|
150
|
-
borderFn: (slot: ThemeSlot, text: string) => string = (slot, text) => theme.fg(slot, text),
|
|
151
|
-
) {
|
|
152
|
-
this.surface = surface;
|
|
153
|
-
this.theme = theme;
|
|
154
|
-
this.borderFn = borderFn;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
get surfaceComponent(): Surface {
|
|
158
|
-
return this.surface;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
setFocused(focused: boolean): void {
|
|
162
|
-
this.focused = focused;
|
|
163
|
-
this.surface.setFocused(focused);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
invalidate(): void {
|
|
167
|
-
this.cacheKey = "";
|
|
168
|
-
this.surface.invalidate();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
render(width: number): string[] {
|
|
172
|
-
const inner = Math.max(2, width - 2);
|
|
173
|
-
// The surface already windows + selection-marks its own lines (each
|
|
174
|
-
// surface owns its scroll/selection state). The pane only wraps them in a
|
|
175
|
-
// border and PADS to fill its allocated height so the multi-pane view looks clean —
|
|
176
|
-
// it must NOT re-window (that double-applies the indent/marker and corrupts
|
|
177
|
-
// the layout). Each body line is sliced/padded to `inner` so borders align.
|
|
178
|
-
const surfaceLines = this.surface.render(inner);
|
|
179
|
-
const title = this.surface.title();
|
|
180
|
-
const active = this.focused;
|
|
181
|
-
const borderSlot = active ? "accent" : "dim";
|
|
182
|
-
const marker = active ? "◆ " : " ";
|
|
183
|
-
const titleText = `${marker}${title} `;
|
|
184
|
-
const top = `${this.borderFn(borderSlot, "┌")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┐")}`;
|
|
185
|
-
const titleVis = visibleWidth(titleText);
|
|
186
|
-
const titleLine = `${this.borderFn(borderSlot, "│")}${this.theme.bold(titleText)}${" ".repeat(Math.max(0, inner - titleVis))}${this.borderFn(borderSlot, "│")}`;
|
|
187
|
-
// top + title + bottom = 3 fixed lines; body fills the rest so both panes
|
|
188
|
-
// in a multi-pane render the same height.
|
|
189
|
-
const bodyRows = Math.max(0, this.rows - 3);
|
|
190
|
-
const out = [top, titleLine];
|
|
191
|
-
for (let i = 0; i < bodyRows; i++) {
|
|
192
|
-
const ln = surfaceLines[i] ?? "";
|
|
193
|
-
const slice = visibleWidth(ln) > inner ? truncateToWidth(ln, inner) : ln;
|
|
194
|
-
const pad = Math.max(0, inner - visibleWidth(slice));
|
|
195
|
-
out.push(`${this.borderFn(borderSlot, "│")}${slice}${" ".repeat(pad)}${this.borderFn(borderSlot, "│")}`);
|
|
196
|
-
}
|
|
197
|
-
const bottom = `${this.borderFn(borderSlot, "└")}${this.borderFn("dim", "─".repeat(inner))}${this.borderFn(borderSlot, "┘")}`;
|
|
198
|
-
out.push(bottom);
|
|
199
|
-
return out;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export type { Component, SurfaceKind };
|
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* surface/detail.ts — the Detail surface as a Component (weave-view-tui-v2 §6).
|
|
3
|
-
*
|
|
4
|
-
* Shows a node's meta, its (real Markdown) body, and links/backlinks. Each
|
|
5
|
-
* Detail pane owns its own bound node + selection/scroll. Enter on a link
|
|
6
|
-
* rebinds the pane in place (decision: a Detail pane re-navigates within
|
|
7
|
-
* itself); the body is rendered through the pi-tui `Markdown` component with
|
|
8
|
-
* a MarkdownTheme mapped from the weave theme (decision 7).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { Markdown, matchesKey, type MarkdownTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
|
-
import { detailModel, sanitizeTerminalText, type DetailModel } from "../model";
|
|
13
|
-
import { kindStyle, provenanceStyle, type ThemeSlot } from "../theme";
|
|
14
|
-
import { type Surface, type SurfaceEvent, type SurfaceInit } from "./base";
|
|
15
|
-
import type { GraphModel } from "../../../../core/graph/model";
|
|
16
|
-
import type { NoteSource } from "../../../../core/types";
|
|
17
|
-
|
|
18
|
-
/** A flattened Detail render line. `id` marks a selectable row (meta/links/
|
|
19
|
-
* backlinks); `target` is the node id an Enter on that row rebinds to. Both
|
|
20
|
-
* are optional and may be explicitly `undefined` (body/header lines have
|
|
21
|
-
* neither) — declared `| undefined` so the object literals below type-check
|
|
22
|
-
* under `exactOptionalPropertyTypes`. */
|
|
23
|
-
type DetailLine = { text: string; id?: string | undefined; target?: string | undefined };
|
|
24
|
-
|
|
25
|
-
export interface DetailSurfaceState {
|
|
26
|
-
nodeId: string | null;
|
|
27
|
-
selectedId: string | null;
|
|
28
|
-
scrollOffset: number;
|
|
29
|
-
/** Goto-line buffer: `null`/undefined = not in goto mode; a string (possibly
|
|
30
|
-
* empty) = the user is typing a line number to jump to. `/` enters goto,
|
|
31
|
-
* digits append, Enter jumps, Backspace deletes, Esc cancels. The workspace
|
|
32
|
-
* root treats a non-null `gotoBuf` as a sub-mode so Esc cancels instead of
|
|
33
|
-
* quitting (mirrors Explore's `searching`). */
|
|
34
|
-
gotoBuf?: string | null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function bindDetail(init: SurfaceInit, nodeId: string): DetailSurface {
|
|
38
|
-
const s = new DetailSurface(init);
|
|
39
|
-
s.state = { nodeId, selectedId: nodeId, scrollOffset: 0 };
|
|
40
|
-
return s;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class DetailSurface implements Surface {
|
|
44
|
-
readonly kind = "detail" as const;
|
|
45
|
-
private readonly ctx;
|
|
46
|
-
private readonly onEvent: ((e: SurfaceEvent) => void) | undefined;
|
|
47
|
-
state: DetailSurfaceState;
|
|
48
|
-
paneRows = 24;
|
|
49
|
-
/** Cached flattened render lines (with ids/targets) from the last render, so
|
|
50
|
-
* handleInput can compute the viewport center / active row + max scroll
|
|
51
|
-
* offset without re-rendering. Bust on invalidate / rebind. */
|
|
52
|
-
private _lines: DetailLine[] | undefined;
|
|
53
|
-
|
|
54
|
-
constructor(init: SurfaceInit) {
|
|
55
|
-
this.ctx = init.context;
|
|
56
|
-
this.onEvent = init.onEvent;
|
|
57
|
-
this.state = { nodeId: init.context.model.nodes[0]?.id ?? null, selectedId: null, scrollOffset: 0 };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
title(): string {
|
|
61
|
-
return "Detail";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
setFocused(_focused: boolean): void {
|
|
65
|
-
// detail has no focus-only state
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
invalidate(): void {
|
|
69
|
-
// Drop the cached line layout so the next render rebuilds it from the
|
|
70
|
-
// current body (BodyStore loads fire onChange at the root → invalidate).
|
|
71
|
-
this._lines = undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
rebind(model: GraphModel): void {
|
|
75
|
-
// After a refresh, a detail pane may be bound to a node that was removed.
|
|
76
|
-
// Rebind to the first surviving node so the pane never shows a stale
|
|
77
|
-
// "(node not found)" after the model is rebuilt.
|
|
78
|
-
if (this.state.nodeId && !model.nodes.some((n) => n.id === this.state.nodeId)) {
|
|
79
|
-
const first = model.nodes[0]?.id ?? null;
|
|
80
|
-
this.state = { nodeId: first, selectedId: first, scrollOffset: 0, gotoBuf: null };
|
|
81
|
-
this._lines = undefined;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
handleInput(data: string): void {
|
|
86
|
-
const paneRows = Math.max(5, this.paneRows);
|
|
87
|
-
if (!this._lines) this._lines = this.buildLines(80);
|
|
88
|
-
const maxOffset = Math.max(0, this._lines.length - paneRows);
|
|
89
|
-
const clamp = (n: number): number => Math.max(0, Math.min(maxOffset, n));
|
|
90
|
-
|
|
91
|
-
// Goto-line sub-mode: digits build a line number, Enter jumps, Esc/bs exit.
|
|
92
|
-
if (this.state.gotoBuf != null) {
|
|
93
|
-
if (matchesKey(data, "escape")) {
|
|
94
|
-
this.state = { ...this.state, gotoBuf: null };
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
if (matchesKey(data, "enter")) {
|
|
98
|
-
const n = parseInt(this.state.gotoBuf, 10);
|
|
99
|
-
this.state = { ...this.state, scrollOffset: clamp(Number.isNaN(n) || n < 1 ? 0 : n - 1), gotoBuf: null };
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (matchesKey(data, "backspace")) {
|
|
103
|
-
const b = this.state.gotoBuf.slice(0, -1);
|
|
104
|
-
this.state = { ...this.state, gotoBuf: b.length ? b : null };
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (data.length === 1 && data >= "0" && data <= "9" && this.state.gotoBuf.length < 5) {
|
|
108
|
-
this.state = { ...this.state, gotoBuf: this.state.gotoBuf + data };
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
return; // ignore unmapped keys while typing a line number
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (data === "o") {
|
|
115
|
-
if (this.state.nodeId) this.onEvent?.({ type: "openEditor", id: this.state.nodeId });
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
// Scroll-primary: arrows/page/home/end move the viewport; the selectable
|
|
119
|
-
// row nearest the viewport center is the active (›) row — Enter opens it.
|
|
120
|
-
if (matchesKey(data, "up")) {
|
|
121
|
-
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset - 1) };
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
if (matchesKey(data, "down")) {
|
|
125
|
-
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset + 1) };
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (matchesKey(data, "pageUp")) {
|
|
129
|
-
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset - paneRows) };
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
if (matchesKey(data, "pageDown")) {
|
|
133
|
-
this.state = { ...this.state, scrollOffset: clamp(this.state.scrollOffset + paneRows) };
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
if (matchesKey(data, "home")) {
|
|
137
|
-
this.state = { ...this.state, scrollOffset: 0 };
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (matchesKey(data, "end")) {
|
|
141
|
-
this.state = { ...this.state, scrollOffset: maxOffset };
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
if (data === "/") {
|
|
145
|
-
this.state = { ...this.state, gotoBuf: "" };
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
if (matchesKey(data, "enter")) {
|
|
149
|
-
const row = this.activeRow();
|
|
150
|
-
if (row?.target) {
|
|
151
|
-
// rebind this detail pane to the target (open in main, in place)
|
|
152
|
-
this.state = { nodeId: row.target, selectedId: row.target, scrollOffset: 0, gotoBuf: null };
|
|
153
|
-
this._lines = undefined;
|
|
154
|
-
this.requestBody(row.target);
|
|
155
|
-
}
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The selectable row (meta/links/backlinks) nearest the viewport center. */
|
|
161
|
-
private activeRow(): { id: string; target?: string | undefined } | undefined {
|
|
162
|
-
const lines = this._lines;
|
|
163
|
-
if (!lines) return undefined;
|
|
164
|
-
const paneRows = Math.max(5, this.paneRows);
|
|
165
|
-
const center = this.state.scrollOffset + Math.floor(paneRows / 2);
|
|
166
|
-
let bestI = -1;
|
|
167
|
-
let best = Infinity;
|
|
168
|
-
for (let i = 0; i < lines.length; i++) {
|
|
169
|
-
const ln = lines[i]!;
|
|
170
|
-
if (!ln.id) continue;
|
|
171
|
-
const d = Math.abs(i - center);
|
|
172
|
-
if (d < best) {
|
|
173
|
-
best = d;
|
|
174
|
-
bestI = i;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return bestI >= 0 ? { id: lines[bestI]!.id!, target: lines[bestI]!.target } : undefined;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Flatten the detail (title + meta + body + links + backlinks) into lines. */
|
|
181
|
-
private buildLines(width: number): DetailLine[] {
|
|
182
|
-
const t = this.ctx.theme;
|
|
183
|
-
const id = this.state.nodeId;
|
|
184
|
-
if (!id) return [];
|
|
185
|
-
const d = detailModel(this.ctx.model, id);
|
|
186
|
-
if (!d) return [];
|
|
187
|
-
const lines: DetailLine[] = [];
|
|
188
|
-
const marker = this.rowMarker(d.kind, d.provenance);
|
|
189
|
-
const provBadge = d.provenance ? ` ${provenanceStyle(d.provenance).glyph} ${provenanceStyle(d.provenance).word}` : "";
|
|
190
|
-
lines.push({ text: t.bold(`${marker}${sanitizeTerminalText(d.label)}`) + t.fg("muted", ` (${d.kind}${provBadge})`) });
|
|
191
|
-
for (const m of d.meta) {
|
|
192
|
-
lines.push({ text: `${t.fg("muted", sanitizeTerminalText(m.label))}: ${sanitizeTerminalText(m.value)}`, id: m.id });
|
|
193
|
-
}
|
|
194
|
-
const body = this.bodyLinesFor(id, width);
|
|
195
|
-
for (const b of body) lines.push({ text: b });
|
|
196
|
-
if (d.links.length > 0) {
|
|
197
|
-
lines.push({ text: t.fg("accent", "Links") });
|
|
198
|
-
for (const lk of d.links) {
|
|
199
|
-
lines.push({ text: ` ${this.rowMarker(lk.kind, lk.provenance)}${sanitizeTerminalText(lk.label)}`, id: lk.id, target: lk.target });
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
if (d.backlinks.length > 0) {
|
|
203
|
-
lines.push({ text: t.fg("accent", "Backlinks") });
|
|
204
|
-
for (const lk of d.backlinks) {
|
|
205
|
-
lines.push({ text: ` ${this.rowMarker(lk.kind, lk.provenance)}${sanitizeTerminalText(lk.label)}`, id: lk.id, target: lk.target });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return lines;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
render(width: number): string[] {
|
|
212
|
-
const t = this.ctx.theme;
|
|
213
|
-
const id = this.state.nodeId;
|
|
214
|
-
if (!id) return [t.fg("dim", "(no selection)")];
|
|
215
|
-
const d = detailModel(this.ctx.model, id);
|
|
216
|
-
if (!d) return [t.fg("dim", "(node not found)")];
|
|
217
|
-
|
|
218
|
-
const lines = this.buildLines(width);
|
|
219
|
-
this._lines = lines;
|
|
220
|
-
const paneRows = Math.max(5, this.paneRows);
|
|
221
|
-
const maxOffset = Math.max(0, lines.length - paneRows);
|
|
222
|
-
const scrollOffset = Math.max(0, Math.min(this.state.scrollOffset, maxOffset));
|
|
223
|
-
if (scrollOffset !== this.state.scrollOffset) {
|
|
224
|
-
this.state = { ...this.state, scrollOffset };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Active row = selectable row nearest the viewport center.
|
|
228
|
-
const center = scrollOffset + Math.floor(paneRows / 2);
|
|
229
|
-
let activeI = -1;
|
|
230
|
-
let best = Infinity;
|
|
231
|
-
for (let i = 0; i < lines.length; i++) {
|
|
232
|
-
if (!lines[i]!.id) continue;
|
|
233
|
-
const dist = Math.abs(i - center);
|
|
234
|
-
if (dist < best) {
|
|
235
|
-
best = dist;
|
|
236
|
-
activeI = i;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
const activeId = activeI >= 0 ? (lines[activeI]!.id ?? null) : null;
|
|
240
|
-
if (this.state.selectedId !== activeId) {
|
|
241
|
-
this.state = { ...this.state, selectedId: activeId };
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const inGoto = this.state.gotoBuf != null;
|
|
245
|
-
// Reserve the last viewport line for the goto prompt when active.
|
|
246
|
-
const contentRows = inGoto ? Math.max(1, paneRows - 1) : paneRows;
|
|
247
|
-
const start = scrollOffset;
|
|
248
|
-
const end = Math.min(lines.length, start + contentRows);
|
|
249
|
-
const marker2 = (text: string) => `› ${this.ctx.theme.bg("selectedBg", text)}`;
|
|
250
|
-
const out: string[] = [];
|
|
251
|
-
for (let i = start; i < end; i++) {
|
|
252
|
-
out.push(i === activeI ? marker2(lines[i]!.text) : ` ${lines[i]!.text}`);
|
|
253
|
-
}
|
|
254
|
-
if (inGoto) {
|
|
255
|
-
out.push(`${t.fg("accent", "/")}${this.state.gotoBuf}`);
|
|
256
|
-
}
|
|
257
|
-
return out.map((l) => truncateToWidth(l, width));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
private rowMarker(kind: string, prov: NoteSource | null): string {
|
|
261
|
-
const ks = kindStyle(kind as never);
|
|
262
|
-
if (kind === "note") {
|
|
263
|
-
const ps = provenanceStyle(prov);
|
|
264
|
-
return ps.glyph ? `${ps.glyph} ` : "";
|
|
265
|
-
}
|
|
266
|
-
return `${this.ctx.theme.fg(ks.slot as ThemeSlot, `${ks.glyph} `)}`;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private requestBody(id: string): void {
|
|
270
|
-
const node = this.ctx.model.nodes.find((n) => n.id === id);
|
|
271
|
-
if (!node) return;
|
|
272
|
-
if (node.kind === "note") this.ctx.bodies.load(id, "note", node.detail.slug);
|
|
273
|
-
else if (node.kind === "file") this.ctx.bodies.load(id, "file", node.detail.path);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** The node body, rendered through the real Markdown component (decision 7). */
|
|
277
|
-
private bodyLinesFor(id: string, width: number): string[] {
|
|
278
|
-
const node = this.ctx.model.nodes.find((n) => n.id === id);
|
|
279
|
-
if (!node || (node.kind !== "note" && node.kind !== "file")) return [];
|
|
280
|
-
if (this.ctx.bodies.isLoading(id)) return [this.ctx.theme.fg("dim", "(loading…)")];
|
|
281
|
-
const body = this.ctx.bodies.get(id);
|
|
282
|
-
if (body === undefined) {
|
|
283
|
-
queueMicrotask(() => this.requestBody(id));
|
|
284
|
-
return [this.ctx.theme.fg("dim", "(loading…)")];
|
|
285
|
-
}
|
|
286
|
-
if (body === null || body === "") return [];
|
|
287
|
-
const md = new Markdown(sanitizeTerminalText(body), 0, 0, markdownTheme(this.ctx.theme), { color: (t) => this.ctx.theme.fg("text", t) }, { renderLatex: false });
|
|
288
|
-
return md.render(Math.max(10, width - 2)).map((l) => ` ${l}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Map the weave theme slots onto a pi-tui MarkdownTheme. */
|
|
293
|
-
export function markdownTheme(theme: {
|
|
294
|
-
fg: (slot: ThemeSlot, text: string) => string;
|
|
295
|
-
bold: (text: string) => string;
|
|
296
|
-
}): MarkdownTheme {
|
|
297
|
-
return {
|
|
298
|
-
heading: (t) => theme.bold(t),
|
|
299
|
-
link: (t) => theme.fg("accent", t),
|
|
300
|
-
linkUrl: (t) => theme.fg("muted", t),
|
|
301
|
-
code: (t) => theme.fg("warning", t),
|
|
302
|
-
codeBlock: (t) => theme.fg("text", t),
|
|
303
|
-
codeBlockBorder: (t) => theme.fg("dim", t),
|
|
304
|
-
quote: (t) => theme.fg("dim", t),
|
|
305
|
-
quoteBorder: (t) => theme.fg("dim", t),
|
|
306
|
-
hr: (t) => theme.fg("dim", t),
|
|
307
|
-
listBullet: (t) => theme.fg("accent", t),
|
|
308
|
-
bold: (t) => theme.bold(t),
|
|
309
|
-
italic: (t) => t,
|
|
310
|
-
strikethrough: (t) => t,
|
|
311
|
-
underline: (t) => t,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export type { DetailModel, GraphModel };
|