@xynogen/pix-runtime 0.10.2 → 0.13.0

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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * binaries-tab.ts — the `/pix` Binaries tab: every catalogued binary, where it
3
+ * resolved from, its version, and controls to install / set / clear a path.
4
+ *
5
+ * Pure view-controller over `binaries/*`; pix-command.ts owns the frame + tabs.
6
+ */
7
+
8
+ import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { ensureTool, type ToolStatus } from "./binaries/ensure.ts";
10
+ import { listTools, type ToolLookup, toolVersion } from "./binaries/resolve.ts";
11
+ import { readBinaryStore, setBinaryChoice, syncBinaryStore } from "./binaries/store.ts";
12
+ import { type IconKey, icon } from "./icon-catalog.ts";
13
+
14
+ export interface TabTheme {
15
+ fg(color: string, text: string): string;
16
+ bold(text: string): string;
17
+ }
18
+
19
+ export interface BinariesTabOptions {
20
+ env: NodeJS.ProcessEnv;
21
+ theme: TabTheme;
22
+ requestRender(): void;
23
+ }
24
+
25
+ export interface TabView {
26
+ header: string[];
27
+ body: string[];
28
+ selectedBodyLine?: number;
29
+ /** Key/action guide pairs. */
30
+ footer: Array<[string, string]>;
31
+ }
32
+
33
+ const STATE_ICON: Record<ToolLookup["state"], IconKey> = {
34
+ ok: "status.ok",
35
+ missing: "status.error",
36
+ broken: "status.warn",
37
+ unsupported: "status.pending",
38
+ };
39
+ const STATE_COLOR: Record<ToolLookup["state"], string> = {
40
+ ok: "success",
41
+ missing: "error",
42
+ broken: "warning",
43
+ unsupported: "muted",
44
+ };
45
+
46
+ export function describeStatus(s: ToolStatus): { text: string; color: string } {
47
+ if (s.kind === "downloading")
48
+ return {
49
+ text: `downloading ${s.name} ${s.version}${s.size ? ` (${s.size})` : ""} from ${s.url}`,
50
+ color: "accent",
51
+ };
52
+ if (s.kind === "installed")
53
+ return {
54
+ text: `installed ${s.name} ${s.version} → ${s.path}${s.verified ? " (sha256 verified)" : ""}`,
55
+ color: "success",
56
+ };
57
+ return { text: `${s.name}: ${s.error}${s.hint ? ` — install: ${s.hint}` : ""}`, color: "error" };
58
+ }
59
+
60
+ /**
61
+ * Shorten a path to `max` columns, keeping both ends (`C:\Program…\bash.exe`):
62
+ * the root says where it lives, the tail says what it is.
63
+ */
64
+ export function middleTruncate(text: string, max: number): string {
65
+ if (text.length <= max) return text;
66
+ if (max <= 1) return "…".slice(0, max);
67
+ const tail = Math.ceil((max - 1) * 0.6);
68
+ const head = max - 1 - tail;
69
+ return `${text.slice(0, head)}…${text.slice(text.length - tail)}`;
70
+ }
71
+
72
+ /** Rows needed on this OS first (alphabetical), then other-platform entries. */
73
+ function ordered(rows: ToolLookup[]): ToolLookup[] {
74
+ const here = rows.filter((r) => r.state !== "unsupported");
75
+ const other = rows.filter((r) => r.state === "unsupported");
76
+ return [...here, ...other];
77
+ }
78
+
79
+ export function createBinariesTab(opts: BinariesTabOptions) {
80
+ const { env, theme } = opts;
81
+ let rows: ToolLookup[] = [];
82
+ let selected = 0;
83
+ let status: { text: string; color: string } | undefined;
84
+ let storeError: string | undefined;
85
+ let storePath = "";
86
+ const versions = new Map<string, string | null>();
87
+ const installing = new Set<string>();
88
+ let editor: { name: string; input: Input } | undefined;
89
+ /** Name/used-by filter. `typing` holds the input while the user types it. */
90
+ let query = "";
91
+ let typing: Input | undefined;
92
+
93
+ /** Rows that match the filter, in display order. */
94
+ const shown = (): ToolLookup[] => {
95
+ const q = query.trim().toLowerCase();
96
+ if (!q) return rows;
97
+ return rows.filter((r) => [r.name, ...r.usedBy].some((t) => t.toLowerCase().includes(q)));
98
+ };
99
+
100
+ const refresh = () => {
101
+ const store = syncBinaryStore(env);
102
+ storeError = store.error;
103
+ storePath = store.path;
104
+ rows = ordered(listTools({ env, store: readBinaryStore(env) }));
105
+ selected = Math.min(selected, Math.max(0, shown().length - 1));
106
+ for (const row of rows) {
107
+ const key = `${row.name}\0${row.path ?? ""}`;
108
+ if (row.state !== "ok" || !row.path || versions.has(key)) continue;
109
+ versions.set(key, null);
110
+ void toolVersion(row.name, row.path).then((v) => {
111
+ versions.set(key, v ?? "");
112
+ opts.requestRender();
113
+ });
114
+ }
115
+ };
116
+
117
+ const install = (row: ToolLookup) => {
118
+ if (installing.has(row.name)) return;
119
+ installing.add(row.name);
120
+ void ensureTool(row.name, {
121
+ env,
122
+ onStatus: (s) => {
123
+ status = describeStatus(s);
124
+ opts.requestRender();
125
+ },
126
+ })
127
+ .catch((err: unknown) => {
128
+ if (status?.color !== "error")
129
+ status = { text: err instanceof Error ? err.message : String(err), color: "error" };
130
+ })
131
+ .finally(() => {
132
+ installing.delete(row.name);
133
+ refresh();
134
+ opts.requestRender();
135
+ });
136
+ };
137
+
138
+ const save = (name: string, value: string | null) => {
139
+ try {
140
+ setBinaryChoice(name, value, env);
141
+ status = value
142
+ ? { text: `${name} → ${value} (saved to binary.json)`, color: "success" }
143
+ : { text: `${name} → automatic (saved to binary.json)`, color: "success" };
144
+ } catch (err) {
145
+ status = { text: err instanceof Error ? err.message : String(err), color: "error" };
146
+ }
147
+ refresh();
148
+ };
149
+
150
+ refresh();
151
+
152
+ return {
153
+ /** True while the path editor or the filter owns the keyboard (esc/tab must not leave the tab). */
154
+ get editing(): boolean {
155
+ return editor !== undefined || typing !== undefined;
156
+ },
157
+
158
+ /** True while the path editor is open. It keeps ↑↓ for itself. */
159
+ get editingPath(): boolean {
160
+ return editor !== undefined;
161
+ },
162
+
163
+ refresh,
164
+
165
+ /** Esc with a kept filter clears it before it closes the overlay. True when it cleared one. */
166
+ clearFilter(): boolean {
167
+ if (!query) return false;
168
+ query = "";
169
+ selected = 0;
170
+ return true;
171
+ },
172
+
173
+ handleInput(data: string, keys: { up: boolean; down: boolean; enter: boolean }): boolean {
174
+ if (editor) {
175
+ editor.input.handleInput(data);
176
+ return true;
177
+ }
178
+ const count = Math.max(1, shown().length);
179
+ if (typing && !keys.up && !keys.down) {
180
+ const input = typing;
181
+ input.handleInput(data);
182
+ // onEscape clears the query and closes the input. Keep that result.
183
+ if (typing === input) query = input.getValue();
184
+ selected = Math.min(selected, Math.max(0, shown().length - 1));
185
+ return true;
186
+ }
187
+ // Other-platform rows are read-only: the cursor reaches them, the actions do not.
188
+ const candidate = shown()[selected];
189
+ const row = candidate?.state === "unsupported" ? undefined : candidate;
190
+ // Moving on dismisses the last action's status so the detail line tracks the cursor.
191
+ if (keys.up) {
192
+ selected = (selected - 1 + count) % count;
193
+ status = undefined;
194
+ } else if (keys.down) {
195
+ selected = (selected + 1) % count;
196
+ status = undefined;
197
+ } else if (keys.enter && row) {
198
+ if (row.downloadable && row.state === "missing") install(row);
199
+ else {
200
+ refresh();
201
+ status = { text: `re-checked ${row.name}`, color: "muted" };
202
+ }
203
+ } else if (matchesKey(data, "e") && row) {
204
+ const input = new Input({ prompt: `${row.name} path: ` });
205
+ input.focused = true;
206
+ // setValue keeps the cursor at 0; paste the prefill as typed text so the
207
+ // cursor sits at the end and ctrl+u clears the whole value.
208
+ const prefill = row.choice ?? row.path ?? "";
209
+ if (prefill) input.handleInput(`\x1b[200~${prefill}\x1b[201~`);
210
+ input.onSubmit = (value) => {
211
+ editor = undefined;
212
+ save(row.name, value.trim() ? value : null);
213
+ opts.requestRender();
214
+ };
215
+ input.onEscape = () => {
216
+ editor = undefined;
217
+ opts.requestRender();
218
+ };
219
+ editor = { name: row.name, input };
220
+ } else if (matchesKey(data, "/")) {
221
+ const input = new Input({ prompt: "filter: " });
222
+ input.focused = true;
223
+ if (query) input.handleInput(`\x1b[200~${query}\x1b[201~`);
224
+ // Live filter: every keystroke narrows the list. Enter keeps it, esc clears it.
225
+ input.onSubmit = () => {
226
+ typing = undefined;
227
+ opts.requestRender();
228
+ };
229
+ input.onEscape = () => {
230
+ typing = undefined;
231
+ query = "";
232
+ selected = 0;
233
+ opts.requestRender();
234
+ };
235
+ typing = input;
236
+ selected = 0;
237
+ } else if (matchesKey(data, "d") && row) save(row.name, null);
238
+ else if (matchesKey(data, "r")) {
239
+ versions.clear();
240
+ refresh();
241
+ status = { text: "re-checked all binaries", color: "muted" };
242
+ } else return false;
243
+ return true;
244
+ },
245
+
246
+ view(width: number): TabView {
247
+ const nameW = Math.max(6, ...rows.map((r) => r.name.length));
248
+ // Frame border + padding take 4 columns; every row must fit on one line.
249
+ const inner = Math.max(20, width - 4);
250
+ const lead = 2 + 2 + nameW + 2; // cursor, glyph, name, gap
251
+ const body: string[] = [];
252
+ let selectedBodyLine: number | undefined;
253
+ let otherHeader = false;
254
+ const list = shown();
255
+ for (let i = 0; i < list.length; i++) {
256
+ const row = list[i] as ToolLookup;
257
+ if (row.state === "unsupported" && !otherHeader) {
258
+ otherHeader = true;
259
+ body.push("", theme.fg("dim", " Other platforms"));
260
+ }
261
+ const sel = i === selected;
262
+ const cursor = sel ? theme.fg("accent", "→") : " ";
263
+ const soft = row.optional && row.state === "missing";
264
+ const glyph = soft
265
+ ? theme.fg("muted", icon("status.pending"))
266
+ : theme.fg(STATE_COLOR[row.state], icon(STATE_ICON[row.state]));
267
+ const nameColor = row.state === "unsupported" ? "muted" : sel ? "accent" : "text";
268
+ const name = theme.fg(nameColor, row.name.padEnd(nameW));
269
+ const busy = installing.has(row.name);
270
+ let detail: string;
271
+ if (row.state === "ok") {
272
+ const version = versions.get(`${row.name}\0${row.path ?? ""}`);
273
+ const meta = [row.source === "user" ? "binary.json" : row.source, version || undefined]
274
+ .filter(Boolean)
275
+ .join(" · ");
276
+ // Path gets what the metadata leaves; metadata is clipped last.
277
+ const tag = ` · ${meta}`;
278
+ const room = inner - lead;
279
+ const pathRoom = Math.max(12, room - Math.min(tag.length, Math.max(0, room - 12)));
280
+ detail = `${theme.fg("dim", middleTruncate(row.path ?? "", pathRoom))}${theme.fg("muted", tag)}`;
281
+ } else if (row.state === "broken") {
282
+ detail = row.error
283
+ ? theme.fg("warning", "blocked: binary.json is invalid")
284
+ : `${theme.fg("warning", `${row.choice} (not found)`)} ${theme.fg("muted", "· binary.json")}`;
285
+ } else if (busy) {
286
+ detail = theme.fg("accent", "installing…");
287
+ } else if (soft) {
288
+ detail = theme.fg("muted", `not installed (optional) · ${row.hint}`);
289
+ } else if (row.state === "missing") {
290
+ detail = row.downloadable
291
+ ? `${theme.fg("warning", "missing")} ${theme.fg("muted", "· enter to install")}`
292
+ : `${theme.fg("warning", "missing")} ${theme.fg("muted", `· ${row.hint}`)}`;
293
+ } else {
294
+ detail = theme.fg("muted", `not used on this OS · ${row.hint}`);
295
+ }
296
+ if (sel) selectedBodyLine = body.length;
297
+ const line = `${cursor} ${glyph} ${name} ${detail}`;
298
+ body.push(visibleWidth(line) > inner ? truncateToWidth(line, inner, "…") : line);
299
+ }
300
+
301
+ const header: string[] = [];
302
+ if (storeError)
303
+ header.push(theme.fg("error", `binary.json is invalid: ${storeError} — fix ${storePath}`));
304
+ else {
305
+ // Keep the file name (binary.json) visible: shorten the path's middle,
306
+ // and drop the lookup order before clipping anything else.
307
+ const order = " (bin → system → PATH → download)";
308
+ const tail = ` · unset = automatic${storePath.length + 7 + 20 + order.length <= inner ? order : ""}`;
309
+ const text = `paths: ${middleTruncate(storePath, Math.max(12, inner - 7 - tail.length))}${tail}`;
310
+ header.push(theme.fg("muted", truncateToWidth(text, inner, "…")));
311
+ }
312
+ // One fixed detail line (editor › action status › selected row) so the
313
+ // list never shifts as the cursor moves.
314
+ const current = list[selected];
315
+ if (editor) header.push(editor.input.render(Math.max(10, width - 4))[0] ?? "");
316
+ else if (typing) header.push(typing.render(Math.max(10, width - 4))[0] ?? "");
317
+ else if (status) header.push(theme.fg(status.color, status.text));
318
+ else if (current)
319
+ header.push(
320
+ `${theme.fg("accent", current.name)} ${theme.fg("muted", `· used by ${current.usedBy.join(", ") || "binary.json"}${current.state === "unsupported" ? " · not used on this OS" : ""}`)}`,
321
+ );
322
+ if (query && !typing)
323
+ header.push(
324
+ `${theme.fg("accent", `filter: ${query}`)} ${theme.fg("muted", `· ${list.length} of ${rows.length} · / change · esc clear`)}`,
325
+ );
326
+
327
+ if (list.length === 0) body.push(theme.fg("muted", ` no binary matches "${query}"`));
328
+ const footer: Array<[string, string]> = editor
329
+ ? [
330
+ ["enter", "save (empty = automatic)"],
331
+ ["esc", "cancel"],
332
+ ]
333
+ : typing
334
+ ? [
335
+ ["↑↓", "move"],
336
+ ["enter", "keep filter"],
337
+ ["esc", "clear filter"],
338
+ ]
339
+ : [
340
+ ["enter", "install/re-check"],
341
+ ["/", "filter"],
342
+ ["e", "set path"],
343
+ ["d", "automatic"],
344
+ ["r", "refresh"],
345
+ ];
346
+ return { header, body, selectedBodyLine, footer };
347
+ },
348
+
349
+ /** Plain-text block for headless `/pix`. */
350
+ summary(): string[] {
351
+ return rows.map((r) => {
352
+ const where =
353
+ r.state === "ok"
354
+ ? `${r.path} (${r.source})`
355
+ : r.state === "broken"
356
+ ? (r.error ?? `${r.choice} (broken)`)
357
+ : r.state;
358
+ return ` ${r.name}: ${where}`;
359
+ });
360
+ },
361
+ };
362
+ }
363
+
364
+ export type BinariesTab = ReturnType<typeof createBinariesTab>;