@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.1

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/src/tui.tsx ADDED
@@ -0,0 +1,362 @@
1
+ // TUI part: a live sidebar block, a details panel, and four commands. Every
2
+ // value comes over RPC from the server part, so the TUI never opens the node,
3
+ // reads the index, or touches the filesystem.
4
+
5
+ import { Plugin } from "@opencode/plugin/tui";
6
+ import { XerjRpc, type StatusSnapshot } from "./rpc.ts";
7
+ import { panelSections, sidebarLines } from "./ui/format.ts";
8
+
9
+ const PANEL_NAME = "xerj.stats";
10
+ const FALLBACK_POLL_MS = 30_000;
11
+ const SEARCH_CLIP = 6_000;
12
+
13
+ interface TuiState {
14
+ lines: string[];
15
+ ready: boolean;
16
+ snapshot: StatusSnapshot | null;
17
+ reason: string | null;
18
+ }
19
+
20
+ export default Plugin.define({
21
+ id: "xerj.tui",
22
+ setup(context) {
23
+ // Read the client on every call: the host replaces it after a server
24
+ // restart, and a captured sub-client then fails without sending anything.
25
+ const rpc = () => context.client.rpc(XerjRpc);
26
+ const [state, update] = context.storage.memory<TuiState>("status", {
27
+ initial: {
28
+ lines: ["XERJ ○ starting"],
29
+ ready: false,
30
+ snapshot: null,
31
+ reason: null,
32
+ },
33
+ });
34
+
35
+ // The RPC lookup is per location; without one the server reports the RPC as
36
+ // unavailable even though this project's copy is registered.
37
+ const at = () =>
38
+ context.location ? { location: context.location } : undefined;
39
+
40
+ const abort = new AbortController();
41
+ let inFlight = false;
42
+ let disposed = false;
43
+ let unsubscribe: (() => void) | null = null;
44
+
45
+ // Subscriptions do not survive a server restart, so they are re-armed
46
+ // against the current client after each successful poll.
47
+ const resubscribe = (): void => {
48
+ if (disposed || unsubscribe) return;
49
+ unsubscribe = subscribe(rpc(), () => void refresh(), abort.signal);
50
+ };
51
+
52
+ const unsubscribeEvents = (): void => {
53
+ unsubscribe?.();
54
+ unsubscribe = null;
55
+ };
56
+
57
+ const refresh = async (): Promise<void> => {
58
+ if (disposed || inFlight) return;
59
+ inFlight = true;
60
+
61
+ try {
62
+ const snapshot = (await rpc().status({}, at())) as StatusSnapshot;
63
+ update((draft) => {
64
+ draft.snapshot = snapshot;
65
+ draft.lines = sidebarLines(snapshot, Date.now());
66
+ draft.ready = snapshot.node.phase === "ready";
67
+ draft.reason = null;
68
+ });
69
+ resubscribe();
70
+ } catch (error) {
71
+ const reason = describe(error);
72
+ unsubscribeEvents();
73
+ update((draft) => {
74
+ draft.snapshot = null;
75
+ draft.reason = reason;
76
+ draft.lines = ["XERJ ○ offline", reason];
77
+ draft.ready = false;
78
+ });
79
+ } finally {
80
+ inFlight = false;
81
+ }
82
+ };
83
+
84
+ const showStatus = async (): Promise<void> => {
85
+ await refresh();
86
+ // Re-running the command closes the panel it opened.
87
+ if (context.ui.panel.current()?.name === PANEL_NAME) {
88
+ context.ui.panel.close();
89
+ return;
90
+ }
91
+ if (context.ui.panel.open(PANEL_NAME)) return;
92
+
93
+ await context.ui.dialog.alert({
94
+ title: "XERJ",
95
+ message: statusText(state.snapshot, state.lines),
96
+ });
97
+ };
98
+
99
+ const reindex = async (): Promise<void> => {
100
+ try {
101
+ const result = (await rpc().reindex({}, at())) as { queued?: boolean };
102
+ context.ui.toast.show({
103
+ message: result.queued
104
+ ? "XERJ reindex queued"
105
+ : "XERJ indexing is disabled",
106
+ variant: result.queued ? "success" : "info",
107
+ });
108
+ } catch {
109
+ context.ui.toast.show({
110
+ message: "XERJ is not available",
111
+ variant: "error",
112
+ });
113
+ }
114
+ };
115
+
116
+ const promptFor = async (title: string, description: string): Promise<string> => {
117
+ const answer = await context.ui.dialog.prompt({ title, description });
118
+ return answer?.trim() ?? "";
119
+ };
120
+
121
+ const remember = async (prefill?: string): Promise<void> => {
122
+ const text =
123
+ prefill?.trim() ||
124
+ (await promptFor("Remember in XERJ", "Durable fact or decision"));
125
+ if (!text) return;
126
+
127
+ try {
128
+ const result = (await rpc().remember({ text }, at())) as {
129
+ id: string;
130
+ created: boolean;
131
+ };
132
+ context.ui.toast.show({
133
+ message: result.created
134
+ ? `Remembered as ${result.id}`
135
+ : `Already remembered as ${result.id}`,
136
+ variant: result.created ? "success" : "info",
137
+ });
138
+ } catch {
139
+ context.ui.toast.show({
140
+ message: "XERJ memory is not available",
141
+ variant: "error",
142
+ });
143
+ }
144
+ };
145
+
146
+ const search = async (prefill?: string): Promise<void> => {
147
+ const query =
148
+ prefill?.trim() ||
149
+ (await promptFor("Search XERJ", "What to look for"));
150
+ if (!query) return;
151
+
152
+ try {
153
+ const result = (await rpc().search({ query }, at())) as {
154
+ text: string;
155
+ hits: number;
156
+ bytes: number;
157
+ };
158
+ await context.ui.dialog.alert({
159
+ title: [
160
+ "XERJ search",
161
+ `${result.hits} ${result.hits === 1 ? "hit" : "hits"}`,
162
+ ].join(" · "),
163
+ message:
164
+ result.hits === 0 ? "No passages matched." : clip(result.text),
165
+ });
166
+ } catch {
167
+ context.ui.toast.show({
168
+ message: "XERJ search is not available",
169
+ variant: "error",
170
+ });
171
+ }
172
+ };
173
+
174
+ void refresh();
175
+ // Events arrive on the first success; the timer covers host or node gaps.
176
+ const timer = setInterval(() => void refresh(), FALLBACK_POLL_MS);
177
+
178
+ context.keymap.layer(() => ({
179
+ mode: "global",
180
+ commands: [
181
+ {
182
+ id: "xerj.status",
183
+ title: "XERJ status",
184
+ description: "Open the XERJ details panel",
185
+ group: "XERJ",
186
+ palette: true,
187
+ slash: { name: "xerj", arguments: true },
188
+ run: async (input) => {
189
+ const text = (input ?? "").trim();
190
+ if (text.length === 0 || text === "status") return showStatus();
191
+
192
+ const [head, ...rest] = text.split(/\s+/);
193
+ const argument = rest.join(" ").trim();
194
+
195
+ if (head === "reindex") return reindex();
196
+ if (head === "remember") return remember(argument);
197
+ if (head === "search") return search(argument);
198
+
199
+ context.ui.toast.show({
200
+ message: `Unknown XERJ command: ${head}`,
201
+ variant: "warning",
202
+ });
203
+ },
204
+ },
205
+ {
206
+ id: "xerj.close",
207
+ title: "Close XERJ panel",
208
+ description: "Close the XERJ details panel",
209
+ group: "XERJ",
210
+ bind: "escape",
211
+ enabled: () => context.ui.panel.current()?.name === PANEL_NAME,
212
+ run: () => context.ui.panel.close(),
213
+ },
214
+ {
215
+ id: "xerj.reindex",
216
+ title: "XERJ reindex",
217
+ description: "Queue an incremental reindex of this project",
218
+ group: "XERJ",
219
+ palette: true,
220
+ slash: { name: "xerj-reindex" },
221
+ enabled: () => state.ready,
222
+ run: () => reindex(),
223
+ },
224
+ {
225
+ id: "xerj.remember",
226
+ title: "XERJ remember",
227
+ description: "Store a durable project fact in XERJ memory",
228
+ group: "XERJ",
229
+ palette: true,
230
+ slash: { name: "xerj-remember", arguments: true },
231
+ enabled: () => state.ready,
232
+ run: (input) => remember(input),
233
+ },
234
+ {
235
+ id: "xerj.search",
236
+ title: "XERJ search",
237
+ description: "Search the project index and show cited passages",
238
+ group: "XERJ",
239
+ palette: true,
240
+ slash: { name: "xerj-search", arguments: true },
241
+ enabled: () => state.ready,
242
+ run: (input) => search(input),
243
+ },
244
+ ],
245
+ }));
246
+
247
+ const detachSidebar = context.ui.slot({
248
+ append: "sidebar.content",
249
+ render: () => (
250
+ <box flexDirection="column">
251
+ {state.lines.map((line) => {
252
+ const color = line.startsWith("XERJ")
253
+ ? context.theme.text.base
254
+ : context.theme.text.muted;
255
+ return <text fg={color}>{line}</text>;
256
+ })}
257
+ </box>
258
+ ),
259
+ });
260
+
261
+ const detachPanel = context.ui.slot({
262
+ append: "session.panel",
263
+ render: (input) => {
264
+ if (input.name !== PANEL_NAME) return null;
265
+
266
+ const snapshot = state.snapshot;
267
+ const sections = snapshot ? panelSections(snapshot, Date.now()) : [];
268
+
269
+ return (
270
+ <box flexDirection="column">
271
+ <scrollbox flexDirection="column">
272
+ {sections.map((section) => (
273
+ <box flexDirection="column" marginBottom={1}>
274
+ <text fg={context.theme.text.base}>{section.title}</text>
275
+ {section.lines.map((line) => (
276
+ <text fg={context.theme.text.muted}>{line}</text>
277
+ ))}
278
+ </box>
279
+ ))}
280
+ {sections.length === 0 ? (
281
+ <text fg={context.theme.text.faint}>
282
+ {state.reason ?? "waiting for the XERJ server part"}
283
+ </text>
284
+ ) : null}
285
+ </scrollbox>
286
+ <box
287
+ flexDirection="row"
288
+ justifyContent="space-between"
289
+ marginTop={1}
290
+ >
291
+ <text fg={context.theme.text.faint}>esc or /xerj closes</text>
292
+ <box
293
+ border
294
+ borderColor={context.theme.border.base}
295
+ paddingLeft={1}
296
+ paddingRight={1}
297
+ onMouseDown={() => input.close()}
298
+ >
299
+ <text fg={context.theme.text.base}>✕ close</text>
300
+ </box>
301
+ </box>
302
+ </box>
303
+ );
304
+ },
305
+ });
306
+
307
+ return () => {
308
+ disposed = true;
309
+ abort.abort();
310
+ clearInterval(timer);
311
+ unsubscribeEvents();
312
+ detachPanel();
313
+ detachSidebar();
314
+ };
315
+ },
316
+ });
317
+
318
+ type EventSource = {
319
+ events?: {
320
+ on?: (
321
+ name: "updated",
322
+ handler: () => void,
323
+ options: { signal: AbortSignal },
324
+ ) => () => void;
325
+ };
326
+ };
327
+
328
+ function subscribe(
329
+ rpc: EventSource,
330
+ handler: () => void,
331
+ signal: AbortSignal,
332
+ ): () => void {
333
+ try {
334
+ return rpc.events?.on?.("updated", handler, { signal }) ?? (() => {});
335
+ } catch {
336
+ return () => {};
337
+ }
338
+ }
339
+
340
+ function statusText(
341
+ snapshot: StatusSnapshot | null,
342
+ lines: ReadonlyArray<string>,
343
+ ): string {
344
+ if (!snapshot) return lines.join("\n");
345
+
346
+ return panelSections(snapshot, Date.now())
347
+ .flatMap((section) => [section.title, ...section.lines])
348
+ .join("\n");
349
+ }
350
+
351
+ // Short, single-line failure reason for the sidebar and the empty panel.
352
+ function describe(error: unknown): string {
353
+ const text = error instanceof Error ? error.message : String(error);
354
+ const first = text.split("\n")[0]?.trim();
355
+ if (!first) return "the XERJ server part did not answer";
356
+ return first.length > 72 ? `${first.slice(0, 71)}…` : first;
357
+ }
358
+
359
+ function clip(text: string): string {
360
+ if (text.length <= SEARCH_CLIP) return text;
361
+ return `${text.slice(0, SEARCH_CLIP)}\n…`;
362
+ }
@@ -0,0 +1,234 @@
1
+ // Presentation only: every function here turns a status snapshot into text.
2
+ // No I/O, no RPC, so the TUI stays thin and the formatting stays testable.
3
+
4
+ import type { StatusSnapshot } from "../rpc.ts";
5
+
6
+ export interface PanelSection {
7
+ readonly title: string;
8
+ readonly lines: ReadonlyArray<string>;
9
+ }
10
+
11
+ const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
12
+ const THOUSAND = 1000;
13
+
14
+ export function formatBytes(value: number): string {
15
+ if (!Number.isFinite(value) || value <= 0) return "0 B";
16
+
17
+ let amount = value;
18
+ let unit = 0;
19
+ while (amount >= 1024 && unit < BYTE_UNITS.length - 1) {
20
+ amount /= 1024;
21
+ unit += 1;
22
+ }
23
+
24
+ // Whole bytes stay whole; scaled units keep one decimal below 100.
25
+ const digits = unit === 0 || amount >= 100 ? 0 : 1;
26
+ return `${amount.toFixed(digits)} ${BYTE_UNITS[unit]}`;
27
+ }
28
+
29
+ export function formatCount(value: number): string {
30
+ if (!Number.isFinite(value)) return "0";
31
+ return Math.round(value).toLocaleString("en-US");
32
+ }
33
+
34
+ export function formatTokens(value: number): string {
35
+ if (!Number.isFinite(value) || value <= 0) return "~0";
36
+ if (value < THOUSAND) return `~${Math.round(value)}`;
37
+
38
+ if (value < THOUSAND * THOUSAND) {
39
+ return `~${(value / THOUSAND).toFixed(1)}K`;
40
+ }
41
+ return `~${(value / (THOUSAND * THOUSAND)).toFixed(1)}M`;
42
+ }
43
+
44
+ export function formatPercent(ratio: number): string {
45
+ if (!Number.isFinite(ratio) || ratio <= 0) return "0%";
46
+ return `${Math.round(ratio * 100)}%`;
47
+ }
48
+
49
+ /** Compact age; `null` is "never" and the first ten seconds are "just now". */
50
+ export function formatAge(now: number, at: number | null): string {
51
+ if (at === null || !Number.isFinite(at)) return "never";
52
+
53
+ const seconds = Math.max(0, Math.round((now - at) / THOUSAND));
54
+ if (seconds < 10) return "just now";
55
+ if (seconds < 60) return `${seconds}s ago`;
56
+
57
+ const minutes = Math.floor(seconds / 60);
58
+ if (minutes < 60) return `${minutes}m ago`;
59
+
60
+ const hours = Math.floor(minutes / 60);
61
+ if (hours < 24) return `${hours}h ago`;
62
+
63
+ return `${Math.floor(hours / 24)}d ago`;
64
+ }
65
+
66
+ /** Four lines for the sidebar slot. */
67
+ export function sidebarLines(snapshot: StatusSnapshot, now: number): string[] {
68
+ const index = snapshot.index;
69
+ const usage = snapshot.usage;
70
+ const records =
71
+ index.lastRecords === null
72
+ ? "no runs"
73
+ : `${formatCount(index.lastRecords)} records`;
74
+
75
+ return [
76
+ `XERJ ${marker(snapshot)} ${headline(snapshot)}`,
77
+ [
78
+ `q ${formatCount(usage.queries)}`,
79
+ `hit ${formatPercent(usage.hitRate)}`,
80
+ `${formatTokens(usage.tokensSaved)} tok saved`,
81
+ ].join(" "),
82
+ `idx ${formatAge(now, index.lastRunAt)} · ${records}`,
83
+ "▸ /xerj for details",
84
+ ];
85
+ }
86
+
87
+ /** Every block of the details panel, in display order. */
88
+ export function panelSections(snapshot: StatusSnapshot, now: number): PanelSection[] {
89
+ const { node, binary, index, memory, usage } = snapshot;
90
+
91
+ return [
92
+ {
93
+ title: "Node",
94
+ lines: [
95
+ [
96
+ node.phase,
97
+ node.spawned ? "spawned" : "attached",
98
+ node.baseUrl ?? "no address",
99
+ ].join(" · "),
100
+ node.serverVersion === null ? "server unknown" : `server ${node.serverVersion}`,
101
+ ],
102
+ },
103
+ {
104
+ title: "Binary",
105
+ lines: [
106
+ `${binary.version ?? "not installed"}${targetSuffix(binary.target)}`,
107
+ binary.path ?? "managed download",
108
+ ],
109
+ },
110
+ {
111
+ title: "Index",
112
+ lines: [
113
+ `${index.state} · ${index.prefix}`,
114
+ [
115
+ `${formatCount(index.docs)} docs`,
116
+ formatBytes(index.bytes),
117
+ `${index.indices} ${plural(index.indices, "index", "indices")}`,
118
+ ].join(" · "),
119
+ [
120
+ `last run ${formatAge(now, index.lastRunAt)}`,
121
+ `generation ${index.lastGeneration ?? "—"}`,
122
+ `${index.lastRecords ?? 0} records`,
123
+ ].join(" · "),
124
+ ],
125
+ },
126
+ {
127
+ title: "Datasets",
128
+ lines:
129
+ index.datasets.length === 0
130
+ ? ["none yet"]
131
+ : index.datasets.map(
132
+ (dataset) =>
133
+ `${dataset.name} ${formatCount(dataset.docs)} docs · ` +
134
+ `${formatBytes(dataset.bytes)}`,
135
+ ),
136
+ },
137
+ ...(snapshot.brains.length === 0
138
+ ? []
139
+ : [
140
+ {
141
+ title: "Brains",
142
+ lines: snapshot.brains.map((brain) =>
143
+ [
144
+ `${brain.name} ${formatCount(brain.docs)} docs`,
145
+ `${brain.edges} ${plural(brain.edges, "edge", "edges")}`,
146
+ formatAge(now, brain.lastRunAt),
147
+ brain.error ?? "",
148
+ ]
149
+ .filter((part) => part.length > 0)
150
+ .join(" · "),
151
+ ),
152
+ },
153
+ ]),
154
+ {
155
+ title: "Memory",
156
+ lines: [
157
+ [
158
+ `${formatCount(memory.count)} ${plural(memory.count, "entry", "entries")}`,
159
+ memory.namespace,
160
+ ].join(" · "),
161
+ ],
162
+ },
163
+ {
164
+ title: "Usage",
165
+ lines: [
166
+ [
167
+ `${formatCount(usage.queries)} queries`,
168
+ `${formatPercent(usage.hitRate)} hit`,
169
+ `${formatCount(usage.hits)} hits`,
170
+ ].join(" · "),
171
+ [
172
+ `${formatTokens(usage.tokensSaved)} tokens saved`,
173
+ `${formatBytes(usage.savedBytes)} of ` +
174
+ `${formatBytes(usage.baselineBytes)} baseline`,
175
+ ].join(" · "),
176
+ `${formatBytes(usage.bytesReturned)} returned · ${formatBytes(usage.bytesRead)} read`,
177
+ ],
178
+ },
179
+ {
180
+ title: "Counters",
181
+ lines: [
182
+ `${formatCount(snapshot.counters.indexRuns)} index runs · ` +
183
+ `${formatCount(snapshot.counters.injectedTurns)} injections`,
184
+ `${formatCount(snapshot.counters.nodeSpawns)} spawns · ` +
185
+ `${formatCount(snapshot.counters.http429)} rate-limited · ` +
186
+ `${formatCount(snapshot.counters.rpcCalls)} rpc calls`,
187
+ ],
188
+ },
189
+ {
190
+ title: "Top cited",
191
+ lines:
192
+ usage.topFiles.length === 0
193
+ ? ["none yet"]
194
+ : usage.topFiles.map(
195
+ (file) => `${file.path} ×${file.citations} · ${formatBytes(file.bytes)}`,
196
+ ),
197
+ },
198
+ {
199
+ title: "Health",
200
+ lines: healthLines(snapshot),
201
+ },
202
+ ];
203
+ }
204
+
205
+ function healthLines(snapshot: StatusSnapshot): string[] {
206
+ const lines = [
207
+ ...snapshot.warnings.map((warning) => `warn: ${warning}`),
208
+ ...snapshot.recent,
209
+ ...snapshot.errors.map((error) => `error: ${error}`),
210
+ ];
211
+
212
+ return lines.length === 0 ? ["no warnings"] : lines.slice(-6);
213
+ }
214
+
215
+ function marker(snapshot: StatusSnapshot): string {
216
+ return snapshot.node.phase === "ready" ? "●" : "○";
217
+ }
218
+
219
+ function headline(snapshot: StatusSnapshot): string {
220
+ const index = snapshot.index;
221
+ if (snapshot.node.phase !== "ready") {
222
+ return snapshot.node.phase === "starting" ? "starting" : snapshot.node.phase;
223
+ }
224
+ if (index.docs === 0) return "ready · not indexed yet";
225
+ return `ready · ${formatCount(index.docs)} docs · ${formatBytes(index.bytes)}`;
226
+ }
227
+
228
+ function targetSuffix(target: string | null): string {
229
+ return target === null ? "" : ` · ${target}`;
230
+ }
231
+
232
+ function plural(count: number, one: string, many: string): string {
233
+ return count === 1 ? one : many;
234
+ }