@ashley-shrok/viewmodel-shell 0.4.1 → 0.4.3

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/dist/tui.js ADDED
@@ -0,0 +1,1477 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { render as inkRender, Box, Text, useInput, useStdin, useStdout, } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import SelectInput from "ink-select-input";
6
+ import { spawn } from "node:child_process";
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ const NO_CTX = {
11
+ focusedKey: null,
12
+ copiedKey: null,
13
+ focusKey: () => undefined,
14
+ interactive: false,
15
+ draft: () => undefined,
16
+ };
17
+ /** OSC 52 clipboard write — the terminal-native analog of the clipboard API.
18
+ * Works over SSH (no local clipboard dependency). Exported for a direct,
19
+ * deterministic unit test of the byte format. */
20
+ export function osc52(text) {
21
+ return `\x1b]52;c;${Buffer.from(String(text), "utf8").toString("base64")}\x07`;
22
+ }
23
+ /** Hand a URL to a real browser — the terminal analog of a redirect. Zero-dep
24
+ * (node:child_process). Opener order: $BROWSER → platform default. Detached +
25
+ * unref so a launched browser can never hold or block the Node event loop
26
+ * (teardown discipline). Returns false only if spawn threw *synchronously*
27
+ * (no opener attempted); an async spawn failure (e.g. ENOENT — no xdg-open)
28
+ * invokes `onSpawnError`. Callers map BOTH false and onSpawnError to the loud
29
+ * interstitial, so a redirect is never silently lost. Exported so a unit test
30
+ * can drive it with a stubbed node:child_process. */
31
+ export function openExternal(url, onSpawnError) {
32
+ const br = process.env.BROWSER;
33
+ const [cmd, args] = br && br.trim()
34
+ ? [br, [url]]
35
+ : process.platform === "darwin"
36
+ ? ["open", [url]]
37
+ : process.platform === "win32"
38
+ ? ["cmd", ["/c", "start", "", url]]
39
+ : ["xdg-open", [url]];
40
+ try {
41
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
42
+ child.once("error", () => onSpawnError?.());
43
+ child.unref();
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ export function classify(url, fromEndpoint) {
51
+ const u = (url ?? "").trim();
52
+ if (!u)
53
+ return { kind: "invalid", url };
54
+ let abs;
55
+ try {
56
+ abs = new URL(u); // absolute?
57
+ }
58
+ catch {
59
+ try {
60
+ // relative → resolve against the current endpoint ⇒ same-origin.
61
+ return { kind: "same-origin", endpoint: new URL(u, fromEndpoint).toString() };
62
+ }
63
+ catch {
64
+ return { kind: "invalid", url };
65
+ }
66
+ }
67
+ try {
68
+ return abs.origin === new URL(fromEndpoint).origin
69
+ ? { kind: "same-origin", endpoint: abs.toString() }
70
+ : { kind: "different-origin", url: abs.toString() };
71
+ }
72
+ catch {
73
+ return { kind: "invalid", url };
74
+ }
75
+ }
76
+ const isTruthyFormValue = (v) => !!v && v !== "false" && v !== "0";
77
+ /** A per-column-filter identity. A `TableColumn` object is reused for the
78
+ * sortable header's focus mapping, so its filter input needs a DISTINCT,
79
+ * stable-within-a-render identity for `map`/`rctx.focusKey`. WeakMap keyed by
80
+ * the column object → the same sentinel in collectFocusables and the renderer
81
+ * of one render pass; a fresh column object each server re-render makes a new
82
+ * sentinel (focus continuity is by key string + reconcile(), never object
83
+ * identity — same as every other focusable). */
84
+ const _filterIdent = new WeakMap();
85
+ function filterIdent(col) {
86
+ let o = _filterIdent.get(col);
87
+ if (!o) {
88
+ o = {};
89
+ _filterIdent.set(col, o);
90
+ }
91
+ return o;
92
+ }
93
+ /** Terminal display width, Unicode-aware enough for table column alignment.
94
+ * Strips CSI/OSC escapes (incl. OSC 8) so a measured cell never includes
95
+ * hyperlink bytes — the Phase-1 string-width over-count, here avoided by not
96
+ * depending on string-width at all (zero new deps). Cells are width-bounded
97
+ * Boxes, so a misestimate of an exotic glyph is at worst cosmetic, never the
98
+ * Phase-1/5a layout corruption. */
99
+ function dispWidth(s) {
100
+ const clean = s
101
+ // eslint-disable-next-line no-control-regex
102
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
103
+ // eslint-disable-next-line no-control-regex
104
+ .replace(/\x1b\[[0-9;]*m/g, "");
105
+ let w = 0;
106
+ for (const ch of clean) {
107
+ const cp = ch.codePointAt(0);
108
+ if (cp === 0)
109
+ continue;
110
+ if ((cp >= 0x300 && cp <= 0x36f) ||
111
+ cp === 0x200d ||
112
+ (cp >= 0xfe00 && cp <= 0xfe0f))
113
+ continue; // combining / ZWJ / variation selectors → width 0
114
+ if ((cp >= 0x1100 && cp <= 0x115f) ||
115
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
116
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
117
+ (cp >= 0xf900 && cp <= 0xfaff) ||
118
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
119
+ (cp >= 0xff00 && cp <= 0xff60) ||
120
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
121
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
122
+ (cp >= 0x20000 && cp <= 0x3fffd))
123
+ w += 2; // wide (CJK / fullwidth / emoji)
124
+ else
125
+ w += 1;
126
+ }
127
+ return w;
128
+ }
129
+ /** Pure pre-pass: every focusable in tree order + an object→key map used by
130
+ * the renderer to highlight the focused node. Same identity heuristic the
131
+ * roadmap locks (id / name / action.name, else positional), made unique
132
+ * deterministically so the ring is unambiguous and stable across renders. */
133
+ function collectFocusables(vm) {
134
+ const list = [];
135
+ const map = new Map();
136
+ const counts = new Map();
137
+ const uniq = (base) => {
138
+ const n = counts.get(base) ?? 0;
139
+ counts.set(base, n + 1);
140
+ return n === 0 ? base : `${base}#${n}`;
141
+ };
142
+ const visit = (node, form) => {
143
+ switch (node.type) {
144
+ case "button": {
145
+ const k = uniq(node.action?.name ?? node.label ?? "button");
146
+ map.set(node, k);
147
+ list.push({ key: k, kind: "button", node, form });
148
+ return;
149
+ }
150
+ case "checkbox": {
151
+ if (!node.action)
152
+ return; // not actionable → not in the ring
153
+ const k = uniq(node.name ?? node.action.name ?? "checkbox");
154
+ map.set(node, k);
155
+ list.push({ key: k, kind: "checkbox", node, form });
156
+ return;
157
+ }
158
+ case "tabs": {
159
+ for (const t of node.tabs ?? []) {
160
+ const k = uniq(`${node.action?.name ?? "tabs"}:${t.value}`);
161
+ map.set(t, k);
162
+ list.push({ key: k, kind: "tabs-tab", node, form, tab: t });
163
+ }
164
+ return;
165
+ }
166
+ case "copy-button": {
167
+ const k = uniq(node.label ?? "copy");
168
+ map.set(node, k);
169
+ list.push({ key: k, kind: "copy", node, form });
170
+ return;
171
+ }
172
+ case "link": {
173
+ if (!(node.href ?? "").trim())
174
+ return; // blank href → plain text (P1)
175
+ const k = uniq(node.href ?? node.label ?? "link");
176
+ map.set(node, k);
177
+ list.push({ key: k, kind: "link", node, form });
178
+ return;
179
+ }
180
+ case "field": {
181
+ const it = node.inputType;
182
+ if (it === "hidden" || it === "file")
183
+ return;
184
+ // invisible (hidden) / deferred (file) → not focusable. textarea/code
185
+ // focusable since Phase 5a (multi-line editor); select/select-multiple
186
+ // focusable since Phase 5b (pickers).
187
+ const k = uniq(node.name ?? "field");
188
+ map.set(node, k);
189
+ list.push({ key: k, kind: "field", node, form });
190
+ return;
191
+ }
192
+ case "form": {
193
+ for (const c of node.children ?? [])
194
+ visit(c, node);
195
+ const k = uniq(node.submitAction?.name ?? "submit");
196
+ map.set(node, k); // the form node carries its synthetic submit's key
197
+ list.push({ key: k, kind: "form-submit", node, form: node });
198
+ return;
199
+ }
200
+ case "modal":
201
+ // Walk body + footer so a modal-rooted call (the focus trap) yields
202
+ // the modal's own focusables. Harmless on a whole-tree walk with no
203
+ // modal present (there are none); the App roots this at the modal
204
+ // when one is open so base focusables are excluded.
205
+ for (const c of node.children ?? [])
206
+ visit(c, form);
207
+ for (const c of node.footer ?? [])
208
+ visit(c, form);
209
+ return;
210
+ case "table": {
211
+ // Phase 5d. Visual/tab order mirrors the browser DOM: sortable
212
+ // headers L→R, then filterable filter inputs L→R, then action rows
213
+ // T→B. Header maps off the `col` object; the filter input maps off a
214
+ // DISTINCT per-column sentinel (filterIdent) so a sortable+filterable
215
+ // column has two unambiguous focus targets. Keys via uniq() keep
216
+ // global uniqueness across multiple tables (the established pattern).
217
+ const t = node;
218
+ const cols = t.columns ?? [];
219
+ if (t.sortAction)
220
+ for (const col of cols)
221
+ if (col.sortable) {
222
+ const k = uniq(`tbl-sort:${col.key}`);
223
+ map.set(col, k);
224
+ list.push({ key: k, kind: "table-sort", node: t, col, form });
225
+ }
226
+ if (t.filterAction)
227
+ for (const col of cols)
228
+ if (col.filterable) {
229
+ const k = uniq(`tbl-filter:${col.key}`);
230
+ map.set(filterIdent(col), k);
231
+ list.push({ key: k, kind: "table-filter", node: t, col, form });
232
+ }
233
+ (t.rows ?? []).forEach((row, ri) => {
234
+ if (row.action) {
235
+ const k = uniq(`tbl-row:${row.id ?? ri}`);
236
+ map.set(row, k);
237
+ list.push({ key: k, kind: "table-row", node: t, row, form });
238
+ }
239
+ });
240
+ return;
241
+ }
242
+ case "page":
243
+ case "section":
244
+ case "list":
245
+ case "list-item":
246
+ for (const c of node.children ?? [])
247
+ visit(c, form);
248
+ return;
249
+ default: // text, stat-bar, progress → not focusable
250
+ return;
251
+ }
252
+ };
253
+ visit(vm);
254
+ return { list, map };
255
+ }
256
+ /** Depth-first first `modal` in the tree. Single-modal is the framework's
257
+ * implied contract; nested/multiple → first wins (documented). Render-only:
258
+ * no wire change. */
259
+ function findModal(node) {
260
+ if (node.type === "modal")
261
+ return node;
262
+ const kids = node.children;
263
+ if (kids)
264
+ for (const c of kids) {
265
+ const m = findModal(c);
266
+ if (m)
267
+ return m;
268
+ }
269
+ const footer = node.footer;
270
+ if (footer)
271
+ for (const c of footer) {
272
+ const m = findModal(c);
273
+ if (m)
274
+ return m;
275
+ }
276
+ return undefined;
277
+ }
278
+ const serverValue = (f) => f.value ?? "";
279
+ /** Collect a form's field values for submission. Mirrors BrowserAdapter:
280
+ * deferred input types skipped, form-checkbox → "true"/"false", hidden
281
+ * included. Values come from `resolve` (draft-aware on the interactive
282
+ * path; server value otherwise — so untyped == Phase 2). */
283
+ function collectForm(form, resolve = serverValue) {
284
+ const out = {};
285
+ const walk = (node) => {
286
+ if (node.type === "field") {
287
+ const f = node;
288
+ const it = f.inputType;
289
+ if (it === "file") {
290
+ return; // deferred input type — not collected
291
+ }
292
+ // select → chosen value; select-multiple → comma-joined values
293
+ // (AGENTS.md "Multi-select submits comma-joined"); both round-trip the
294
+ // draft-aware `resolve` (Phase 5b), exactly like the text family.
295
+ if (it === "checkbox")
296
+ out[f.name] = isTruthyFormValue(resolve(f)) ? "true" : "false";
297
+ else
298
+ out[f.name] = resolve(f);
299
+ return;
300
+ }
301
+ const kids = node.children;
302
+ if (kids)
303
+ for (const c of kids)
304
+ walk(c);
305
+ };
306
+ for (const c of form.children ?? [])
307
+ walk(c);
308
+ return out;
309
+ }
310
+ function submitOf(form, resolve = serverValue) {
311
+ return {
312
+ name: form.submitAction.name,
313
+ context: { ...(form.submitAction.context ?? {}), ...collectForm(form, resolve) },
314
+ };
315
+ }
316
+ /** The single-line `field` input types that become an editable `<TextInput>`
317
+ * when focused on a TTY. NOT: hidden (invisible), checkbox (toggle),
318
+ * textarea/code (multi-line — handled by isEditableMultiLine + the
319
+ * MultilineEditor), or the still-deferred tier (select/select-multiple/file).
320
+ * Unknown types fall through to text — same fail-soft as the renderer. */
321
+ function isEditableSingleLine(it) {
322
+ return (it !== "hidden" &&
323
+ it !== "checkbox" &&
324
+ it !== "textarea" &&
325
+ it !== "code" &&
326
+ it !== "select" &&
327
+ it !== "select-multiple" &&
328
+ it !== "file");
329
+ }
330
+ /** Phase 5a: the multi-line `field` input types that become an editable
331
+ * `<MultilineEditor>` when focused on a TTY. `code` is rendered identically
332
+ * to `textarea` plus a dim language-hint label; literal-tab insertion is
333
+ * intentionally deferred (Tab always traverses the focus ring — the locked
334
+ * input-arbitration invariant). */
335
+ function isEditableMultiLine(it) {
336
+ return it === "textarea" || it === "code";
337
+ }
338
+ /** Phase 5b: the `field` input types that become an interactive picker when
339
+ * focused on a TTY — `select` (single, via `ink-select-input`) and
340
+ * `select-multiple` (multi, via the contained `MultiSelectInput`). Not a text
341
+ * editor (so deliberately NOT in isEditableSingleLine); it joins the editing
342
+ * gate so the focused picker owns Up/Down/Enter/Space while App keeps Tab/
343
+ * Shift-Tab (ring) + Ctrl-C (teardown). */
344
+ function isSelect(it) {
345
+ return it === "select" || it === "select-multiple";
346
+ }
347
+ /** Reconcile the focused key against a (possibly new) focusable list — the
348
+ * roadmap's continuity rule: keep it if still present; else clamp to the
349
+ * prior index position; else first; else none. */
350
+ function reconcile(keys, focusedKey, prev) {
351
+ if (keys.length === 0)
352
+ return null;
353
+ if (focusedKey && keys.includes(focusedKey))
354
+ return focusedKey;
355
+ if (prev && prev.key) {
356
+ const pi = prev.keys.indexOf(prev.key);
357
+ if (pi >= 0)
358
+ return keys[Math.min(pi, keys.length - 1)] ?? keys[0];
359
+ }
360
+ return keys[0];
361
+ }
362
+ /** Full-screen loud notice (failed/external/invalid redirect, storage I/O
363
+ * failure). Pure render — NO useInput of its own: it is shown by swapping
364
+ * App's rendered subtree, App's existing sole useInput stays mounted, so the
365
+ * unconditional Ctrl-C → requestExit branch still quits and the Phase 0–3
366
+ * teardown topology is unchanged (no new input hook). */
367
+ function Interstitial({ msg }) {
368
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "\u26A0 Action required" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: msg }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Ctrl-C to quit." }) })] }));
369
+ }
370
+ /**
371
+ * Phase 5a contained multi-line editor for `textarea`/`code`. Built ONLY on
372
+ * Ink's already-vetted primitives (useInput key-decode, Box, Text) — there is
373
+ * no mature Ink-5/React-18-compatible multi-line lib (every option is pre-1.0
374
+ * and either abandoned or forces a forbidden Ink 6/7 + React 19 bump), so a
375
+ * contained editor is the only path respecting the locked toolkit + the
376
+ * zero-blast-radius invariant. Zero new dependencies.
377
+ *
378
+ * Controlled (value/onChange — the App draft map is the single source of
379
+ * truth, exactly like the single-line `<TextInput>`). Internal {row,col}
380
+ * caret in useState, held in the stable component instance (key="input" under
381
+ * the stable App root) so it survives the shell's instance.rerender() — the
382
+ * Phase-3 caret-continuity mechanism, one level down.
383
+ *
384
+ * Input contract MIRRORS ink-text-input so the two-handler arbitration with
385
+ * App's root useInput stays collision-free: this editor EARLY-RETURNS Ctrl-C
386
+ * (App owns teardown → requestExit(130)) and Tab/Shift-Tab (App owns ring
387
+ * traversal — the locked invariant; `code` is therefore NOT literal-tab
388
+ * aware, by design), and owns char insert / Enter→newline / Backspace+Delete
389
+ * / Left/Right (wrapping across lines) / Up/Down (clamped). A multi-char paste
390
+ * burst is inserted verbatim; embedded CR/LF split lines (Phase-3 paste rule).
391
+ * Form submission stays the focus ring's submit button (the user Tabs out) —
392
+ * Enter never submits a multi-line field; field `action`/Enter-dispatch is
393
+ * N/A for multi-line.
394
+ */
395
+ function MultilineEditor(props) {
396
+ const split = (s) => (s.length === 0 ? [""] : s.split("\n"));
397
+ // Caret starts at the END of the initial value (focusing a pre-filled field
398
+ // puts the caret after the text — mirrors ink-text-input's UX). Lazy
399
+ // initializer runs ONCE at mount, never on the shell's rerenders, so caret
400
+ // continuity holds (the Phase-3 mechanism, one level down).
401
+ const [cursor, setCursor] = useState(() => {
402
+ const ls = split(props.value);
403
+ return { row: ls.length - 1, col: (ls[ls.length - 1] ?? "").length };
404
+ });
405
+ const lines = split(props.value);
406
+ // Clamp for render (value may have shrunk via server-wins before the effect
407
+ // below re-syncs the stored cursor).
408
+ // Re-clamp the stored caret when the controlled value shrinks (mirrors
409
+ // ink-text-input's clamp effect). Guarded → no extra render when unchanged.
410
+ useEffect(() => {
411
+ const ls = split(props.value);
412
+ setCursor((c) => {
413
+ const r = Math.min(c.row, ls.length - 1);
414
+ const col = Math.min(c.col, (ls[r] ?? "").length);
415
+ return r === c.row && col === c.col ? c : { row: r, col };
416
+ });
417
+ }, [props.value]);
418
+ useInput((input, key) => {
419
+ // App owns these — never consume them (Ink calls every useInput; no
420
+ // bubbling, so a plain return cedes the key).
421
+ if (key.ctrl && input === "c")
422
+ return; // → App requestExit(130)
423
+ if (key.tab)
424
+ return; // Tab / Shift-Tab → App ring traversal (locked)
425
+ const cur = split(props.value);
426
+ const r = Math.min(cursor.row, cur.length - 1);
427
+ const c = Math.min(cursor.col, (cur[r] ?? "").length);
428
+ const commit = (next, nr, nc) => {
429
+ props.onChange(next.join("\n"));
430
+ setCursor({ row: nr, col: nc });
431
+ };
432
+ if (key.return) {
433
+ const line = cur[r] ?? "";
434
+ const next = [
435
+ ...cur.slice(0, r),
436
+ line.slice(0, c),
437
+ line.slice(c),
438
+ ...cur.slice(r + 1),
439
+ ];
440
+ commit(next, r + 1, 0);
441
+ return;
442
+ }
443
+ if (key.leftArrow) {
444
+ if (c > 0)
445
+ setCursor({ row: r, col: c - 1 });
446
+ else if (r > 0)
447
+ setCursor({ row: r - 1, col: (cur[r - 1] ?? "").length });
448
+ return;
449
+ }
450
+ if (key.rightArrow) {
451
+ if (c < (cur[r] ?? "").length)
452
+ setCursor({ row: r, col: c + 1 });
453
+ else if (r < cur.length - 1)
454
+ setCursor({ row: r + 1, col: 0 });
455
+ return;
456
+ }
457
+ if (key.upArrow) {
458
+ if (r > 0)
459
+ setCursor({
460
+ row: r - 1,
461
+ col: Math.min(c, (cur[r - 1] ?? "").length),
462
+ });
463
+ return;
464
+ }
465
+ if (key.downArrow) {
466
+ if (r < cur.length - 1)
467
+ setCursor({
468
+ row: r + 1,
469
+ col: Math.min(c, (cur[r + 1] ?? "").length),
470
+ });
471
+ return;
472
+ }
473
+ if (key.backspace || key.delete) {
474
+ // Backspace semantics (delete the char BEFORE the caret) for both —
475
+ // matches ink-text-input, so single- and multi-line editing feel
476
+ // identical. Forward-delete is intentionally deferred.
477
+ if (c > 0) {
478
+ const line = cur[r] ?? "";
479
+ const next = [...cur];
480
+ next[r] = line.slice(0, c - 1) + line.slice(c);
481
+ commit(next, r, c - 1);
482
+ }
483
+ else if (r > 0) {
484
+ const prev = cur[r - 1] ?? "";
485
+ const next = [
486
+ ...cur.slice(0, r - 1),
487
+ prev + (cur[r] ?? ""),
488
+ ...cur.slice(r + 1),
489
+ ];
490
+ commit(next, r - 1, prev.length);
491
+ }
492
+ return;
493
+ }
494
+ if (input && !key.ctrl && !key.meta) {
495
+ // Printable / paste burst. Normalize CR(LF) so a pasted multi-line
496
+ // block splits cleanly.
497
+ const text = input.replace(/\r\n?/g, "\n");
498
+ const line = cur[r] ?? "";
499
+ const parts = (line.slice(0, c) + text + line.slice(c)).split("\n");
500
+ const next = [...cur.slice(0, r), ...parts, ...cur.slice(r + 1)];
501
+ const last = parts[parts.length - 1];
502
+ commit(next, r + parts.length - 1, parts.length === 1 ? c + text.length : last.length);
503
+ }
504
+ }, { isActive: props.focus });
505
+ const empty = props.value.length === 0;
506
+ if (empty && !props.focus) {
507
+ return (_jsx(Text, { dimColor: true, children: props.placeholder ? props.placeholder : " " }));
508
+ }
509
+ return (_jsx(Box, { flexDirection: "column", children: lines.map((ln, i) => (
510
+ // One flat <Text> per line. A nested in-text caret element corrupts
511
+ // Ink/Yoga width measurement inside the live focusWrap + bordered-box
512
+ // tree (the Phase-1 mixed-<Text> width lesson — verified: split caret
513
+ // wraps "hello" into per-char lines; one flat <Text> renders clean).
514
+ // Focus is signalled by focusWrap's leading ▸; the caret is tracked
515
+ // internally for edit logic. Rendering a caret glyph is a documented
516
+ // deferred polish (would also fragment the value-substring tests).
517
+ _jsx(Text, { children: ln === "" ? " " : ln }, i))) }));
518
+ }
519
+ /**
520
+ * Phase 5b contained multi-select picker for `select-multiple`. Built ONLY on
521
+ * Ink's already-vetted primitives — `ink-select-input` (the locked, mature
522
+ * Ink-5/React-18 lib used for single `select`) is single-select only, and no
523
+ * mature Ink-5/React-18 multi-select lib documents the Tab/Shift-Tab/Ctrl-C
524
+ * pass-through this codebase's input-arbitration depends on (`ink-multi-select`
525
+ * is dead — ink^3/react^16; `@inkjs/ui` MultiSelect's key handling is
526
+ * undocumented). Same precedent + rationale as Phase-5a's `MultilineEditor`:
527
+ * contained component, zero new deps, exact known keyboard contract.
528
+ *
529
+ * CONTROLLED by `value` (comma-joined, the wire shape — AGENTS.md "Multi-select
530
+ * submits comma-joined"; Showcase emits `value:"a,c"`). The selected SET is
531
+ * derived from `value` every render, so it is server-authoritative by
532
+ * construction: when App drops the select draft on a server re-render (selects
533
+ * are excluded from draft preservation — AGENTS.md), `value` becomes the server
534
+ * value and the rendered selection follows. Only the highlight index is
535
+ * internal (a cursor, not a value — fine to persist across re-renders, like
536
+ * MultilineEditor's caret).
537
+ *
538
+ * Input contract MIRRORS MultilineEditor/ink-text-input: EARLY-RETURNS Ctrl-C
539
+ * (App owns teardown → requestExit(130)) and Tab/Shift-Tab (App owns ring
540
+ * traversal — locked); owns Up/Down (move highlight) and Space (toggle the
541
+ * highlighted option). Enter is inert here (App's editing branch also returns
542
+ * on Enter) — submission stays the ring's submit button (the user Tabs out),
543
+ * exactly like a browser multi-select element.
544
+ */
545
+ function MultiSelectInput(props) {
546
+ const parse = (s) => s
547
+ .split(",")
548
+ .map((x) => x.trim())
549
+ .filter((x) => x.length > 0);
550
+ const opts = props.options;
551
+ const [hi, setHi] = useState(0);
552
+ const selected = new Set(parse(props.value));
553
+ useInput((input, key) => {
554
+ // App owns these — never consume them (Ink calls every useInput; no
555
+ // bubbling, so a plain return cedes the key).
556
+ if (key.ctrl && input === "c")
557
+ return; // → App requestExit(130)
558
+ if (key.tab)
559
+ return; // Tab / Shift-Tab → App ring traversal (locked)
560
+ if (key.upArrow) {
561
+ setHi((h) => (opts.length ? (h - 1 + opts.length) % opts.length : 0));
562
+ return;
563
+ }
564
+ if (key.downArrow) {
565
+ setHi((h) => (opts.length ? (h + 1) % opts.length : 0));
566
+ return;
567
+ }
568
+ if (input === " ") {
569
+ const o = opts[hi];
570
+ if (!o)
571
+ return;
572
+ const next = new Set(parse(props.value));
573
+ if (next.has(o.value))
574
+ next.delete(o.value);
575
+ else
576
+ next.add(o.value);
577
+ // Preserve option order in the comma-joined wire value.
578
+ const joined = opts
579
+ .filter((x) => next.has(x.value))
580
+ .map((x) => x.value)
581
+ .join(",");
582
+ props.onChange(joined);
583
+ return;
584
+ }
585
+ // Enter / anything else: inert (App's editing branch also returns).
586
+ }, { isActive: props.focus });
587
+ if (opts.length === 0)
588
+ return _jsx(Text, { dimColor: true, children: " " });
589
+ return (_jsx(Box, { flexDirection: "column", children: opts.map((o, i) => {
590
+ const on = selected.has(o.value);
591
+ const cur = i === hi;
592
+ // ONE flat <Text> per row — a nested styled span mid-string corrupts
593
+ // Ink/Yoga width measurement inside the live focusWrap+border tree
594
+ // (the Phase-1/5a mixed-<Text> width lesson).
595
+ return (_jsx(Text, { color: cur ? "cyan" : undefined, children: (cur ? "▸ " : " ") + (on ? "[x] " : "[ ] ") + o.label }, o.value));
596
+ }) }));
597
+ }
598
+ /**
599
+ * Phase 5d — table per-column filter editor. A thin wrapper over
600
+ * `ink-text-input` (already a dependency since Phase 3 — ZERO new deps),
601
+ * mounted ONLY when its filter cell is focused on a TTY (the select/multiline
602
+ * precedent). ink-text-input@6 early-returns Tab/Shift-Tab/Up/Down/Ctrl-C, so
603
+ * App keeps ring traversal + teardown; it owns char/Backspace/Left/Right and
604
+ * fires `onSubmit` on Enter → the filterAction dispatch (browser.ts parity).
605
+ * A focused table-filter also joins App's `editing` gate (kind
606
+ * "table-filter") so ring-mode arrow handling can't collide with the editor's
607
+ * cursor — the two input handlers never fight.
608
+ */
609
+ function TableFilterInput(props) {
610
+ return (_jsx(TextInput, { focus: props.focus, value: props.value, placeholder: "filter\u2026", onChange: props.onChange, onSubmit: props.onSubmit }));
611
+ }
612
+ /** The stateful root. Mounted ONCE; the shell's poll/dispatch re-renders
613
+ * reconcile the SAME component instance (React preserves its focus state) —
614
+ * this is why a stable root component, not a bare tree. */
615
+ function App(props) {
616
+ const { vm, renderWith } = props;
617
+ const { isRawModeSupported } = useStdin();
618
+ const { write } = useStdout();
619
+ const interactive = isRawModeSupported;
620
+ const [focusedKey, setFocusedKey] = useState(null);
621
+ const [copiedKey, setCopiedKey] = useState(null);
622
+ // User-typed field drafts, keyed by focus key. Mirrors BrowserAdapter's
623
+ // draft-preservation: a draft survives the shell's poll/dispatch
624
+ // re-renders unless the field disappears or the SERVER changes that
625
+ // field's authoritative value (then the server wins).
626
+ const [draft, setDraft] = useState({});
627
+ // Phase 5c: a modal traps focus. Detect it (interactive only — non-TTY/unit
628
+ // renders the whole tree inline, preserving the Phase-1 non-TTY contract +
629
+ // deterministic static tests) and root the focus ring at the modal subtree
630
+ // so base focusables are excluded while it is open.
631
+ const modal = interactive ? findModal(vm) : undefined;
632
+ const { list, map } = collectFocusables(modal ?? vm);
633
+ const keys = list.map((d) => d.key);
634
+ const prevRef = useRef(undefined);
635
+ const effective = interactive
636
+ ? reconcile(keys, focusedKey, prevRef.current)
637
+ : null;
638
+ // Latest-value refs so the (single, stable) input handler never goes stale.
639
+ const onActionRef = useRef(props.onAction);
640
+ onActionRef.current = props.onAction;
641
+ const requestExitRef = useRef(props.requestExit);
642
+ requestExitRef.current = props.requestExit;
643
+ // Same single useInput — NOT a new hook. While an interstitial is shown the
644
+ // ring is inert (only Ctrl-C acts) so a stray key can't dispatch into the
645
+ // shell behind a terminal notice. Mirrors the existing editingRef guard.
646
+ const interstitialActiveRef = useRef(props.interstitial != null);
647
+ interstitialActiveRef.current = props.interstitial != null;
648
+ // Phase 5c — same single useInput (NOT a new hook): a ref-gated Esc branch,
649
+ // exactly mirroring interstitialActiveRef. tui-cli.ts stays unchanged ⇒
650
+ // teardown topology identical to Phase 4 (safe by construction; PTY-verified).
651
+ const modalActiveRef = useRef(modal != null);
652
+ modalActiveRef.current = modal != null;
653
+ const dismissActionRef = useRef(modal?.dismissAction ?? null);
654
+ dismissActionRef.current = modal?.dismissAction ?? null;
655
+ const listRef = useRef(list);
656
+ listRef.current = list;
657
+ const effectiveRef = useRef(effective);
658
+ effectiveRef.current = effective;
659
+ const writeRef = useRef(write);
660
+ writeRef.current = write;
661
+ const copyTimer = useRef(undefined);
662
+ // Per-render snapshot of every DRAFTABLE focusable's server value, vs the
663
+ // previous render's snapshot — the change detector for "server wins".
664
+ // Draftable = `field` (Phase 3/5a/5b) + Phase-5d `table-filter` inputs
665
+ // (server value = the column's `filterValue`). The field path is
666
+ // byte-identical to Phase 5b — table-filter rows are purely ADDITIVE, so
667
+ // every existing field/draft test is unaffected.
668
+ const draftableDescs = list.filter((d) => d.kind === "field" || d.kind === "table-filter");
669
+ const serverValOf = (d) => d.kind === "table-filter"
670
+ ? (d.col.filterValue ?? "")
671
+ : (d.node.value ?? "");
672
+ const fieldKeysNow = new Set(draftableDescs.map((d) => d.key));
673
+ const fieldServerNow = {};
674
+ for (const d of draftableDescs)
675
+ fieldServerNow[d.key] = serverValOf(d);
676
+ const prevServerRef = useRef({});
677
+ // Phase 5b: focus keys of select/select-multiple fields. Selects are
678
+ // EXCLUDED from draft preservation (AGENTS.md: can't distinguish
679
+ // "server set this" from "user changed it") — so a select draft is
680
+ // server-authoritative the moment a SERVER re-render arrives, even if that
681
+ // field's value is unchanged (the inverse of the text draft-survival rule).
682
+ const selectKeysNow = new Set(draftableDescs
683
+ .filter((d) => d.kind === "field" && isSelect(d.node.inputType))
684
+ .map((d) => d.key));
685
+ // A server re-render is observable as a new `vm` object identity (the shell
686
+ // passes a freshly-parsed vm each render(); a local setState re-render keeps
687
+ // the SAME vm prop). First render: undefined → not a "server" render.
688
+ const prevVmRef = useRef(undefined);
689
+ const isServerRender = prevVmRef.current !== undefined && prevVmRef.current !== vm;
690
+ /** Effective draft for a focus key: the typed value when it should still
691
+ * win — i.e. there IS a draft, the field still exists, and the server
692
+ * hasn't changed that field's value since we last saw it. Otherwise
693
+ * undefined → caller falls back to the server value. */
694
+ const draftFor = (k) => {
695
+ if (!(k in draft))
696
+ return undefined;
697
+ if (!fieldKeysNow.has(k))
698
+ return undefined; // field / table-filter gone
699
+ if (prevServerRef.current[k] !== fieldServerNow[k])
700
+ return undefined; // server changed → authoritative
701
+ if (selectKeysNow.has(k) && isServerRender)
702
+ return undefined; // selects: server-authoritative across ANY server re-render (AGENTS.md — excluded from draft preservation)
703
+ return draft[k];
704
+ };
705
+ const resolve = (f) => {
706
+ const k = map.get(f);
707
+ const dv = k != null ? draftFor(k) : undefined;
708
+ return dv ?? f.value ?? "";
709
+ };
710
+ const focusedDesc = list.find((d) => d.key === effective);
711
+ const editing = interactive &&
712
+ ((focusedDesc?.kind === "field" &&
713
+ (isEditableSingleLine(focusedDesc.node.inputType) ||
714
+ isEditableMultiLine(focusedDesc.node.inputType) ||
715
+ isSelect(focusedDesc.node.inputType))) ||
716
+ // Phase 5d: a focused table FILTER input is an editable <TextInput>; it
717
+ // joins the editing gate so App cedes char/Left/Right/Enter to it and
718
+ // keeps ONLY Tab/Shift-Tab (ring) + Ctrl-C (teardown). Without this,
719
+ // ring-mode arrow handling would collide with the editor's cursor and
720
+ // Down/Right would jump focus mid-type. The `field` disjunct above is
721
+ // byte-identical → zero field/form behavior change. table-sort /
722
+ // table-row stay ring-mode (Enter/Space → activate).
723
+ focusedDesc?.kind === "table-filter");
724
+ const editingRef = useRef(editing);
725
+ editingRef.current = editing;
726
+ useEffect(() => {
727
+ if (effective !== focusedKey)
728
+ setFocusedKey(effective);
729
+ prevRef.current = { keys, key: effective };
730
+ });
731
+ // Prune stale drafts (field gone, or server changed its value) and record
732
+ // this render's server snapshot for the next render's change detection.
733
+ // Guarded so an unchanged draft map keeps its identity → no extra render.
734
+ useEffect(() => {
735
+ setDraft((prev) => {
736
+ let changed = false;
737
+ const next = {};
738
+ for (const k of Object.keys(prev)) {
739
+ const stale = !fieldKeysNow.has(k) ||
740
+ prevServerRef.current[k] !== fieldServerNow[k] ||
741
+ (selectKeysNow.has(k) && isServerRender); // selects never survive a server re-render
742
+ if (stale) {
743
+ changed = true;
744
+ continue;
745
+ }
746
+ next[k] = prev[k];
747
+ }
748
+ return changed ? next : prev;
749
+ });
750
+ prevServerRef.current = fieldServerNow;
751
+ prevVmRef.current = vm;
752
+ });
753
+ useEffect(() => () => {
754
+ if (copyTimer.current)
755
+ clearTimeout(copyTimer.current);
756
+ }, []);
757
+ const doCopy = (key, text) => {
758
+ try {
759
+ writeRef.current(osc52(text));
760
+ }
761
+ catch {
762
+ /* OSC 52 unsupported terminals: silent, like the browser clipboard */
763
+ }
764
+ setCopiedKey(key);
765
+ if (copyTimer.current)
766
+ clearTimeout(copyTimer.current);
767
+ copyTimer.current = setTimeout(() => setCopiedKey(null), 1500);
768
+ };
769
+ /** Field Enter (its own action → dispatch {[name]: current}; else submit
770
+ * the enclosing form). `current` is the draft-aware resolved value. */
771
+ const submitField = (d) => {
772
+ const dispatch = onActionRef.current;
773
+ const node = d.node;
774
+ if (node.action) {
775
+ const cur = draftFor(d.key) ?? node.value ?? "";
776
+ dispatch({
777
+ name: node.action.name,
778
+ context: { ...(node.action.context ?? {}), [node.name]: cur },
779
+ });
780
+ }
781
+ else if (d.form) {
782
+ dispatch(submitOf(d.form, resolve));
783
+ }
784
+ };
785
+ /** Space on a form-`checkbox` field → flip its draft boolean. */
786
+ const toggleCheckboxDraft = (d) => {
787
+ const node = d.node;
788
+ const cur = draftFor(d.key) ?? node.value ?? "";
789
+ const nextVal = isTruthyFormValue(cur) ? "false" : "true";
790
+ setDraft((s) => ({ ...s, [d.key]: nextVal }));
791
+ };
792
+ const onFieldSubmit = (field) => {
793
+ const k = map.get(field);
794
+ const d = list.find((x) => x.key === k);
795
+ if (d)
796
+ submitField(d);
797
+ };
798
+ /** Phase 5d — a table per-column filter's Enter. Mirrors BrowserAdapter
799
+ * (browser.ts): dispatch `filterAction` merged with { column, value,
800
+ * filters }, where `filters` is EVERY filterable column's current text
801
+ * (draft else server `filterValue`) and `value` is this column's. */
802
+ const tableFilter = (table, col) => {
803
+ const fa = table.filterAction;
804
+ if (!fa)
805
+ return;
806
+ const textOf = (c) => {
807
+ const fk = map.get(filterIdent(c));
808
+ const dv = fk != null ? draftFor(fk) : undefined;
809
+ return dv ?? c.filterValue ?? "";
810
+ };
811
+ const filters = {};
812
+ for (const c of table.columns ?? [])
813
+ if (c.filterable)
814
+ filters[c.key] = textOf(c);
815
+ onActionRef.current({
816
+ name: fa.name,
817
+ context: {
818
+ ...(fa.context ?? {}),
819
+ column: col.key,
820
+ value: textOf(col),
821
+ filters,
822
+ },
823
+ });
824
+ };
825
+ const activate = (d, trigger) => {
826
+ const dispatch = onActionRef.current;
827
+ switch (d.kind) {
828
+ case "button":
829
+ dispatch({
830
+ name: d.node.action.name,
831
+ context: { ...(d.node.action.context ?? {}) },
832
+ });
833
+ break;
834
+ case "checkbox":
835
+ if (d.node.action)
836
+ dispatch({
837
+ name: d.node.action.name,
838
+ context: { ...(d.node.action.context ?? {}), checked: !d.node.checked },
839
+ });
840
+ break;
841
+ case "tabs-tab":
842
+ dispatch({
843
+ name: d.node.action.name,
844
+ context: { ...(d.node.action.context ?? {}), value: d.tab.value },
845
+ });
846
+ break;
847
+ case "field": {
848
+ const node = d.node;
849
+ if (node.inputType === "checkbox") {
850
+ if (trigger === "space")
851
+ toggleCheckboxDraft(d);
852
+ else
853
+ submitField(d); // Enter on a form-checkbox → submit / its action
854
+ }
855
+ else {
856
+ // Editable single-line fields are driven by ink-text-input's
857
+ // onSubmit in editing mode and never reach here; this is a safety
858
+ // net (e.g. a field somehow activated in ring mode) — treat as submit.
859
+ submitField(d);
860
+ }
861
+ break;
862
+ }
863
+ case "form-submit":
864
+ dispatch(submitOf(d.node, resolve));
865
+ break;
866
+ case "copy":
867
+ doCopy(d.key, d.node.text);
868
+ break;
869
+ case "link":
870
+ doCopy(d.key, d.node.href);
871
+ break;
872
+ case "table-sort": {
873
+ // browser.ts parity: toggle asc↔desc only when this column is the
874
+ // current sortColumn AND was asc; otherwise default to asc.
875
+ const t = d.node;
876
+ const col = d.col;
877
+ const sa = t.sortAction;
878
+ if (!col || !sa)
879
+ break;
880
+ const isSorted = t.sortColumn === col.key;
881
+ const nextDir = isSorted && t.sortDirection === "asc" ? "desc" : "asc";
882
+ dispatch({
883
+ name: sa.name,
884
+ context: {
885
+ ...(sa.context ?? {}),
886
+ column: col.key,
887
+ direction: nextDir,
888
+ },
889
+ });
890
+ break;
891
+ }
892
+ case "table-row":
893
+ // Verbatim, no merge — mirrors BrowserAdapter `on(rowAction)`.
894
+ if (d.row?.action)
895
+ dispatch(d.row.action);
896
+ break;
897
+ case "table-filter":
898
+ // Editable: the focused <TextInput>'s onSubmit (→ rctx.onTableFilter)
899
+ // owns the dispatch. Unreachable while editing (App returns before the
900
+ // ring-activate line); a defensive no-op for any edge path.
901
+ break;
902
+ }
903
+ };
904
+ useInput((input, key) => {
905
+ if (key.ctrl && input === "c") {
906
+ requestExitRef.current(130);
907
+ return;
908
+ }
909
+ if (interstitialActiveRef.current)
910
+ return; // notice up: only Ctrl-C acts
911
+ if (modalActiveRef.current && key.escape) {
912
+ // Esc dismisses a modal IFF it carries a dismissAction (AGENTS.md:
913
+ // no dismissAction & no footer ⇒ non-dismissible — never synthesize a
914
+ // close). Placed before the ring-empty + editing branches so a
915
+ // text-only dismissible modal still closes, and Esc cancels even from
916
+ // within a body field (ink-text-input/MultilineEditor don't consume
917
+ // Esc). Non-Esc keys fall through to the trapped ring below.
918
+ if (dismissActionRef.current)
919
+ onActionRef.current(dismissActionRef.current);
920
+ return;
921
+ }
922
+ const ring = listRef.current;
923
+ if (ring.length === 0)
924
+ return;
925
+ let idx = ring.findIndex((d) => d.key === effectiveRef.current);
926
+ if (idx < 0)
927
+ idx = 0;
928
+ const goNext = () => setFocusedKey(ring[(idx + 1) % ring.length].key);
929
+ const goPrev = () => setFocusedKey(ring[(idx - 1 + ring.length) % ring.length].key);
930
+ if (editingRef.current) {
931
+ // Editing a field: ink-text-input owns char insert / Backspace /
932
+ // Delete / Left / Right and fires onSubmit on Enter. App keeps ONLY
933
+ // ring traversal (Tab/Shift-Tab) + Ctrl-C (handled above). Everything
934
+ // else is intentionally inert so the two input handlers don't collide.
935
+ // (ink-text-input itself early-returns Tab/Shift-Tab/Up/Down/Ctrl-C,
936
+ // so those reach this handler cleanly.)
937
+ if (key.tab && !key.shift)
938
+ goNext();
939
+ else if (key.tab && key.shift)
940
+ goPrev();
941
+ return;
942
+ }
943
+ // Ring mode — exactly Phase-2 behavior.
944
+ if ((key.tab && !key.shift) || key.downArrow || key.rightArrow)
945
+ goNext();
946
+ else if ((key.tab && key.shift) || key.upArrow || key.leftArrow)
947
+ goPrev();
948
+ else if (key.return || input === " ")
949
+ activate(ring[idx], key.return ? "enter" : "space");
950
+ }, { isActive: interactive });
951
+ const rctx = {
952
+ focusedKey: effective,
953
+ copiedKey,
954
+ focusKey: (o) => map.get(o),
955
+ interactive,
956
+ draft: (k) => draftFor(k),
957
+ onFieldChange: (k, v) => setDraft((s) => ({ ...s, [k]: v })),
958
+ onFieldSubmit,
959
+ onTableFilter: tableFilter,
960
+ };
961
+ if (props.interstitial != null)
962
+ return _jsx(Interstitial, { msg: props.interstitial });
963
+ if (modal)
964
+ // Screen-ownership: render ONLY the modal, horizontally centered (the
965
+ // honest terminal "z-layer" — Ink has no z-index; base tree suppressed).
966
+ // Vertical centering deferred (no reliable terminal-height center in Ink;
967
+ // cosmetic, not load-bearing).
968
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: renderWith(modal, rctx) }));
969
+ return renderWith(vm, rctx);
970
+ }
971
+ /**
972
+ * Phase 5b terminal adapter — single-line + multi-line editors + selects.
973
+ *
974
+ * Every node renders byte-identically to Phase 1/2 when UNFOCUSED and on the
975
+ * pure `renderTree`/non-TTY path (NO_CTX → interactive:false → no editor). On
976
+ * a TTY the shell's view is interactive: a self-managed focus ring
977
+ * (Tab/Shift-Tab/arrows), Enter/Space dispatch, focus continuity; button /
978
+ * checkbox (`{checked}`) / tabs (`{value}`) / link / copy-button (OSC 52) /
979
+ * form-submit. The focused single-line `field` is an editable
980
+ * `ink-text-input`; the focused `textarea`/`code` field is the contained
981
+ * `MultilineEditor` (Enter→newline; submit via the ring's submit button;
982
+ * `code` adds a dim language label, no literal-tab). The focused `select` is
983
+ * an `ink-select-input` list; the focused `select-multiple` is the contained
984
+ * `MultiSelectInput` (Space toggles; comma-joined wire value; submit via the
985
+ * ring's submit button). Typed drafts survive poll/dispatch re-renders unless
986
+ * the field disappears or the server changes its value — mirroring
987
+ * BrowserAdapter; selects are additionally server-authoritative on ANY server
988
+ * re-render (excluded from draft preservation); password is masked;
989
+ * form-`checkbox` toggles on Space. The still-deferred tier (`file`/`modal`/
990
+ * `table`) arrives in later phases and still renders a visible fail-loud
991
+ * placeholder — never blank, never silent.
992
+ *
993
+ * LEAF MODULE: never imported by src/index.ts / src/browser.ts /
994
+ * src/server.ts, so `ink`/`react` never enter the web or server dependency
995
+ * graph (a unit test asserts this).
996
+ */
997
+ export class TuiAdapter {
998
+ instance;
999
+ disposed = false;
1000
+ /** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
1001
+ * mode, is delivered as input 0x03 and never raises SIGINT) reach the
1002
+ * CLI's single idempotent shutdown. A TUI-internal seam between our two
1003
+ * leaf files — NOT part of the core Adapter interface. */
1004
+ requestExit = () => { };
1005
+ /** session storage — in-memory, process lifetime (browser sessionStorage
1006
+ * analog for a single-process CLI). Write-only per the wire contract. */
1007
+ sessionStore = new Map();
1008
+ /** When non-null, App renders the loud Interstitial instead of the vm. */
1009
+ interstitial = null;
1010
+ /** Last rendered vm/onAction — so showInterstitial can rerender through the
1011
+ * SAME single Ink instance (never a 2nd inkRender). */
1012
+ lastVm;
1013
+ lastOnAction = () => { };
1014
+ setRequestExit(fn) {
1015
+ this.requestExit = fn;
1016
+ }
1017
+ render(vm, onAction) {
1018
+ this.lastVm = vm;
1019
+ this.lastOnAction = onAction;
1020
+ this.interstitial = null; // a fresh view supersedes any prior notice
1021
+ const app = this.createApp(vm, onAction);
1022
+ if (!this.instance) {
1023
+ // exitOnCtrlC:false — the CLI is the single owner of teardown; Ink must
1024
+ // not race it. Ctrl-C is handled in App and routed via requestExit.
1025
+ this.instance = inkRender(app, { exitOnCtrlC: false });
1026
+ }
1027
+ else {
1028
+ // The shell re-invokes render() on every poll/dispatch; rerender keeps
1029
+ // one Ink instance (and one App instance → focus state survives).
1030
+ this.instance.rerender(app);
1031
+ }
1032
+ }
1033
+ /** The interactive element used by render() and by interaction unit tests. */
1034
+ createApp(vm, onAction, opts) {
1035
+ return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
1036
+ }
1037
+ /** Render a full-screen loud notice through the SINGLE existing Ink instance
1038
+ * (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
1039
+ * diff / the ESC[?25h restore). No-op once disposed — closes the
1040
+ * redirect-during-teardown rerender-after-unmount race. Process stays
1041
+ * alive; the CLI owns exit (Ctrl-C via App's existing useInput → shutdown).
1042
+ * Mounts the instance if a notice somehow precedes the first render (that
1043
+ * is still the FIRST inkRender, not a second). */
1044
+ showInterstitial(msg) {
1045
+ if (this.disposed)
1046
+ return;
1047
+ this.interstitial = msg;
1048
+ const vm = this.lastVm ?? { type: "text", value: "" };
1049
+ const app = this.createApp(vm, this.lastOnAction);
1050
+ if (this.instance)
1051
+ this.instance.rerender(app);
1052
+ else
1053
+ this.instance = inkRender(app, { exitOnCtrlC: false });
1054
+ }
1055
+ /** Standalone redirect fallback. Only reached when a consumer wires
1056
+ * TuiAdapter WITHOUT ShellOptions.onRedirect — core precedence means the
1057
+ * vms-tui CLI's onRedirect suppresses this (proven by adapter-seam C/F).
1058
+ * Origin-blind (the adapter has no shell/endpoint ref): hand off to a
1059
+ * browser, else the loud interstitial. Never silent, never throws into
1060
+ * core. No-op once disposed. */
1061
+ navigate(url) {
1062
+ if (this.disposed)
1063
+ return;
1064
+ const fallback = () => this.showInterstitial(`Open this URL to continue:\n\n ${url}\n\n(no browser could be launched — open it manually)`);
1065
+ if (!openExternal(url, fallback))
1066
+ fallback();
1067
+ }
1068
+ /** Write-only client storage. `session` → in-memory (process lifetime).
1069
+ * `local` → a SYNCHRONOUS XDG state-file write — synchronous is mandatory:
1070
+ * the core applies side-effects before the redirect branch (adapter-seam
1071
+ * Case E), so the token must land before navigation. Fail-loud, never
1072
+ * swallow, never re-throw into core (push() has no try/catch): an I/O error
1073
+ * or an unparseable EXISTING store surfaces the loud interstitial + a
1074
+ * stderr line and does NOT clobber a possibly-unrelated user file. */
1075
+ storage(scope, key, value) {
1076
+ if (scope === "session") {
1077
+ this.sessionStore.set(key, value);
1078
+ return;
1079
+ }
1080
+ const xdg = process.env.XDG_STATE_HOME;
1081
+ const base = xdg && xdg.trim() ? xdg : join(homedir(), ".local", "state");
1082
+ const dir = join(base, "vms-tui");
1083
+ const file = join(dir, "storage.json");
1084
+ try {
1085
+ mkdirSync(dir, { recursive: true });
1086
+ let existing;
1087
+ try {
1088
+ existing = readFileSync(file, "utf8");
1089
+ }
1090
+ catch {
1091
+ existing = undefined; // ENOENT → fresh store (not a failure)
1092
+ }
1093
+ const obj = existing !== undefined
1094
+ ? JSON.parse(existing) // throw → caught: no clobber
1095
+ : {};
1096
+ obj[key] = value;
1097
+ writeFileSync(file, JSON.stringify(obj));
1098
+ }
1099
+ catch (err) {
1100
+ const m = err.message;
1101
+ try {
1102
+ process.stderr.write(`vms-tui: storage write failed (local "${key}"): ${m}\n`);
1103
+ }
1104
+ catch {
1105
+ /* stderr unavailable — the interstitial below is still loud */
1106
+ }
1107
+ this.showInterstitial(`Storage write FAILED — your data was NOT saved.\n\n key: ${key}\n error: ${m}`);
1108
+ }
1109
+ }
1110
+ /** Test-only: read back a session value. The wire contract has no storage
1111
+ * read; this exists solely so a unit test can prove the write landed
1112
+ * (same rationale as exporting osc52 for a deterministic test). */
1113
+ _peekSession(key) {
1114
+ return this.sessionStore.get(key);
1115
+ }
1116
+ /** Pure ViewNode → Ink element, UNFOCUSED (NO_CTX). Used by static unit
1117
+ * tests; byte-identical to Phase 1 output. */
1118
+ renderTree(vm) {
1119
+ return this.renderNode(vm, 0, "comfortable", undefined, NO_CTX);
1120
+ }
1121
+ // ── helpers ──────────────────────────────────────────────────────────────
1122
+ /** Spacing rhythm. compact collapses gaps/padding (mirrors .vms--compact). */
1123
+ spacing(d) {
1124
+ return d === "compact" ? { gap: 0, pad: 0 } : { gap: 1, pad: 1 };
1125
+ }
1126
+ /**
1127
+ * React key: the roadmap's identity heuristic — explicit id/name where
1128
+ * present, else positional index. Render-only; never a wire field.
1129
+ */
1130
+ keyOf(node, i) {
1131
+ const n = node;
1132
+ return n.id ?? n.name ?? `${node.type}-${i}`;
1133
+ }
1134
+ /** Fail-loud placeholder — single source of the phase string. */
1135
+ unsupported(label, key) {
1136
+ return (_jsxs(Text, { color: "yellow", children: ["[unsupported: ", label, " \u2014 phase 5]"] }, key));
1137
+ }
1138
+ /** Focused-node affordance. Unfocused → returns the element unchanged, so
1139
+ * the static path stays byte-identical to Phase 1. Focused → a leading
1140
+ * cyan caret (deterministic + information-honest; ▸ U+25B8 is distinct
1141
+ * from the list-item markers › / ·). */
1142
+ focusWrap(el, focused, key) {
1143
+ if (!focused)
1144
+ return el;
1145
+ // Children get fixed structural keys ("caret"/"el") that are unique within
1146
+ // THIS 2-element array and cannot collide with any node-derived key (the
1147
+ // wrapped element is the sole child of its own Box, so its own key is
1148
+ // irrelevant here). The wrapper carries `key` for its parent sibling list.
1149
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "cyan", bold: true, children: "▸ " }, "caret"), _jsx(Box, { children: el }, "el")] }, key));
1150
+ }
1151
+ /** Shipped list-item variant → marker + child tint (fail-soft: unknown ok). */
1152
+ listItemStyle(variant) {
1153
+ switch (variant) {
1154
+ case "active":
1155
+ return { marker: "›", mc: "cyan", mb: true, child: { bold: true } };
1156
+ case "done":
1157
+ return { marker: "✓", mc: "green", md: true, child: { dim: true } };
1158
+ case "critical":
1159
+ return { marker: "●", mc: "red", mb: true, child: { color: "red" } };
1160
+ case "high":
1161
+ return { marker: "●", mc: "red", child: { color: "red" } };
1162
+ case "warning":
1163
+ return { marker: "▲", mc: "yellow", child: { color: "yellow" } };
1164
+ case "success":
1165
+ return { marker: "●", mc: "green", child: { color: "green" } };
1166
+ case "info":
1167
+ return { marker: "●", mc: "blue", child: { color: "blue" } };
1168
+ default:
1169
+ return { marker: "·", md: true };
1170
+ }
1171
+ }
1172
+ /** page & section share the 4 layout presets. Information-honest, not pixel. */
1173
+ layoutContainer(layout, children, density, rctx) {
1174
+ const sp = this.spacing(density);
1175
+ const kids = (children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, undefined, rctx));
1176
+ switch (layout) {
1177
+ case "split":
1178
+ return (_jsx(Box, { flexDirection: "row", gap: sp.gap || 1, children: kids.map((el, i) => (_jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: el }, i))) }));
1179
+ case "cards":
1180
+ return (_jsx(Box, { flexDirection: "row", flexWrap: "wrap", gap: sp.gap || 1, children: kids.map((el, i) => (_jsx(Box, { flexGrow: 0, flexShrink: 1, flexBasis: 28, minWidth: 20, children: el }, i))) }));
1181
+ case "sidebar": {
1182
+ if (kids.length === 0)
1183
+ return _jsx(Box, {});
1184
+ const [rail, ...rest] = kids;
1185
+ if (rest.length === 0) {
1186
+ return (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }));
1187
+ }
1188
+ return (_jsxs(Box, { flexDirection: "row", gap: sp.gap || 1, children: [_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }), _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: _jsx(Box, { flexDirection: "column", gap: sp.gap, children: rest }) })] }));
1189
+ }
1190
+ default: // "stack" / undefined
1191
+ return (_jsx(Box, { flexDirection: "column", gap: sp.gap, children: kids }));
1192
+ }
1193
+ }
1194
+ // ── the node switch ──────────────────────────────────────────────────────
1195
+ renderNode(node, key, density = "comfortable", inherited, rctx = NO_CTX) {
1196
+ switch (node.type) {
1197
+ case "page": {
1198
+ const d = node.density === "compact" ? "compact" : "comfortable";
1199
+ const sp = this.spacing(d);
1200
+ return (_jsxs(Box, { flexDirection: "column", gap: sp.gap, children: [node.title ? (_jsx(Box, { children: _jsx(Text, { bold: true, underline: true, children: node.title }) })) : null, this.layoutContainer(node.layout, node.children ?? [], d, rctx)] }, key));
1201
+ }
1202
+ case "section": {
1203
+ const sp = this.spacing(density);
1204
+ const card = node.variant === "card";
1205
+ return (_jsxs(Box, { flexDirection: "column", gap: sp.gap, borderStyle: card ? "round" : undefined, paddingX: card ? Math.max(1, sp.pad) : undefined, paddingY: card ? (sp.gap ? 1 : 0) : undefined, children: [node.heading ? (_jsx(Box, { children: _jsx(Text, { bold: true, children: node.heading }) })) : null, this.layoutContainer(node.layout, node.children ?? [], density, rctx)] }, key));
1206
+ }
1207
+ case "list":
1208
+ return (_jsx(Box, { flexDirection: "column", children: (node.children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, inherited, rctx)) }, key));
1209
+ case "list-item": {
1210
+ const st = this.listItemStyle(node.variant);
1211
+ const childInherited = st.child ?? inherited;
1212
+ return (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: st.mc, bold: st.mb, dimColor: st.md, children: st.marker }), _jsx(Box, { flexDirection: "row", gap: 1, children: (node.children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, childInherited, rctx)) })] }, key));
1213
+ }
1214
+ case "text": {
1215
+ const s = node.style;
1216
+ return (_jsx(Text, { bold: s === "heading" || s === "subheading" || inherited?.bold === true, italic: s === "subheading", dimColor: s === "muted" || inherited?.dim === true, color: s === "error" ? "red" : inherited?.color, strikethrough: s === "strikethrough", children: node.value }, key));
1217
+ }
1218
+ case "link": {
1219
+ const fk = rctx.focusKey(node);
1220
+ const focused = fk != null && fk === rctx.focusedKey;
1221
+ const copied = fk != null && fk === rctx.copiedKey;
1222
+ const href = (node.href ?? "").trim();
1223
+ if (!href) {
1224
+ return (_jsx(Box, { children: _jsx(Text, { underline: true, color: inherited?.color, dimColor: inherited?.dim, children: node.label }) }, key));
1225
+ }
1226
+ // OSC 8 hyperlink. As the SOLE child of its own Box with no wrap, the
1227
+ // string-width over-count (string-width does not strip OSC 8) stays
1228
+ // contained to this line and cannot corrupt sibling layout.
1229
+ const osc = `]8;;${node.href}${node.label}]8;;${copied ? " ✓ copied" : ""}`;
1230
+ return this.focusWrap(_jsx(Box, { children: _jsx(Text, { wrap: "truncate-end", underline: true, color: inherited?.color, dimColor: inherited?.dim, children: osc }) }, key), focused, key);
1231
+ }
1232
+ case "stat-bar":
1233
+ return (_jsx(Box, { flexDirection: "row", gap: 2, children: (node.stats ?? []).map((st, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: String(st.value) }), _jsx(Text, { dimColor: true, children: st.label })] }, `${st.label}-${i}`))) }, key));
1234
+ case "progress": {
1235
+ const raw = Number(node.value);
1236
+ const v = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 0;
1237
+ const width = 20;
1238
+ const filled = Math.round((v / 100) * width);
1239
+ const bar = "█".repeat(filled) + "░".repeat(width - filled);
1240
+ return (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { children: bar }), _jsxs(Text, { dimColor: true, children: [Math.round(v), "%"] })] }, key));
1241
+ }
1242
+ case "button": {
1243
+ const fk = rctx.focusKey(node);
1244
+ const focused = fk != null && fk === rctx.focusedKey;
1245
+ const variant = node.variant;
1246
+ const color = variant === "primary"
1247
+ ? "cyan"
1248
+ : variant === "danger"
1249
+ ? "red"
1250
+ : inherited?.color;
1251
+ const bold = variant === "primary" || variant === "danger" || inherited?.bold === true;
1252
+ return this.focusWrap(_jsx(Box, { borderStyle: "round", paddingX: 1, children: _jsx(Text, { bold: bold, color: color, dimColor: inherited?.dim, children: node.label }) }, key), focused, key);
1253
+ }
1254
+ case "checkbox": {
1255
+ const fk = rctx.focusKey(node);
1256
+ const focused = fk != null && fk === rctx.focusedKey;
1257
+ return this.focusWrap(_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { children: node.checked ? "[x]" : "[ ]" }), node.label ? (_jsx(Text, { bold: inherited?.bold === true, color: inherited?.color, dimColor: inherited?.dim, children: node.label })) : null] }, key), focused, key);
1258
+ }
1259
+ case "tabs":
1260
+ return (_jsx(Box, { flexDirection: "row", gap: 1, children: (node.tabs ?? []).map((t, i) => {
1261
+ const on = t.value === node.selected;
1262
+ const fk = rctx.focusKey(t);
1263
+ const focused = fk != null && fk === rctx.focusedKey;
1264
+ const el = on ? (_jsx(Box, { borderStyle: "round", paddingX: 1, children: _jsx(Text, { bold: true, color: "cyan", children: t.label }) }, t.value ?? i)) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: t.label }) }, t.value ?? i));
1265
+ return this.focusWrap(el, focused, t.value ?? i);
1266
+ }) }, key));
1267
+ case "copy-button": {
1268
+ const fk = rctx.focusKey(node);
1269
+ const focused = fk != null && fk === rctx.focusedKey;
1270
+ const copied = fk != null && fk === rctx.copiedKey;
1271
+ const label = copied
1272
+ ? (node.copiedLabel ?? "Copied!")
1273
+ : (node.label ?? "Copy");
1274
+ return this.focusWrap(_jsx(Box, { borderStyle: "round", paddingX: 1, children: _jsx(Text, { children: label }) }, key), focused, key);
1275
+ }
1276
+ case "form": {
1277
+ const inline = node.layout === "inline";
1278
+ const fk = rctx.focusKey(node);
1279
+ const focused = fk != null && fk === rctx.focusedKey;
1280
+ return (_jsxs(Box, { flexDirection: inline ? "row" : "column", gap: 1, alignItems: inline ? "flex-start" : undefined, children: [(node.children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, undefined, rctx)), this.focusWrap(_jsx(Box, { borderStyle: "round", paddingX: 1, children: _jsx(Text, { bold: true, color: "cyan", children: node.submitLabel ?? "Submit" }) }), focused, "submit")] }, key));
1281
+ }
1282
+ case "field": {
1283
+ const it = node.inputType;
1284
+ if (it === "hidden")
1285
+ return _jsx(Box, {}, key);
1286
+ const fk = rctx.focusKey(node);
1287
+ const focused = fk != null && fk === rctx.focusedKey;
1288
+ const dval = fk != null ? rctx.draft(fk) : undefined;
1289
+ if (it === "checkbox") {
1290
+ const cur = dval ?? node.value;
1291
+ const truthy = !!cur && cur !== "false" && cur !== "0";
1292
+ return this.focusWrap(_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { children: truthy ? "[x]" : "[ ]" }), node.label ? _jsx(Text, { children: node.label }) : null] }, key), focused, key);
1293
+ }
1294
+ if (it === "textarea" || it === "code") {
1295
+ // Phase 5a — multi-line editor. `code` == textarea + a dim language
1296
+ // hint label (no syntax coloring: the framework only ships the
1297
+ // editable monospaced surface; a terminal is already monospace).
1298
+ const current = dval ?? node.value ?? "";
1299
+ const labelText = (node.label ?? "") +
1300
+ (it === "code" && node.language ? ` [${node.language}]` : "");
1301
+ if (rctx.interactive && focused && fk != null) {
1302
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [labelText ? _jsx(Text, { dimColor: true, children: labelText }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(MultilineEditor, { focus: true, value: current, placeholder: node.placeholder ?? "", onChange: (v) => rctx.onFieldChange?.(fk, v) }, "input") })] }, key), focused, key);
1303
+ }
1304
+ const hasValue = current !== "";
1305
+ const display = hasValue ? current : (node.placeholder ?? "");
1306
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [labelText ? _jsx(Text, { dimColor: true, children: labelText }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(Text, { dimColor: !hasValue, children: display === "" ? " " : display }) })] }, key), focused, key);
1307
+ }
1308
+ if (it === "file") {
1309
+ return this.unsupported(`field(${it})`, key);
1310
+ }
1311
+ if (it === "select" || it === "select-multiple") {
1312
+ // Phase 5b — interactive picker. `select` → ink-select-input (its
1313
+ // useInput owns Up/Down/Enter/digits and does NOT consume Tab/
1314
+ // Shift-Tab/Ctrl-C — verified — so App keeps ring + teardown).
1315
+ // `select-multiple` → the contained MultiSelectInput (same keyboard
1316
+ // contract). Editor mounts ONLY when focused on a TTY; otherwise
1317
+ // (and on the pure NO_CTX/non-TTY path) a static label box,
1318
+ // byte-identical when no draft exists.
1319
+ const opts = node.options ?? [];
1320
+ const multi = it === "select-multiple";
1321
+ const current = dval ?? node.value ?? "";
1322
+ const labelText = node.label ?? "";
1323
+ if (rctx.interactive && focused && fk != null) {
1324
+ if (multi) {
1325
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [labelText ? _jsx(Text, { dimColor: true, children: labelText }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(MultiSelectInput, { focus: true, options: opts, value: current, onChange: (v) => rctx.onFieldChange?.(fk, v) }, "input") })] }, key), focused, key);
1326
+ }
1327
+ const items = opts.map((o) => ({ label: o.label, value: o.value }));
1328
+ const idx = items.findIndex((i) => i.value === current);
1329
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [labelText ? _jsx(Text, { dimColor: true, children: labelText }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(SelectInput, { isFocused: true, items: items, initialIndex: idx < 0 ? 0 : idx, onSelect: (i) => rctx.onFieldChange?.(fk, String(i.value)) }, `sel:${current}`) })] }, key), focused, key);
1330
+ }
1331
+ // Static: selected label(s). ONE flat <Text> for the value line
1332
+ // (Phase-1/5a nested-<Text> width pitfall).
1333
+ const labelOf = (val) => opts.find((o) => o.value === val)?.label ?? val;
1334
+ const display = multi
1335
+ ? current
1336
+ .split(",")
1337
+ .map((s) => s.trim())
1338
+ .filter((s) => s.length > 0)
1339
+ .map(labelOf)
1340
+ .join(", ")
1341
+ : current
1342
+ ? labelOf(current)
1343
+ : "";
1344
+ const hasValue = display !== "";
1345
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [labelText ? _jsx(Text, { dimColor: true, children: labelText }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(Text, { dimColor: !hasValue, children: hasValue ? display : (node.placeholder ?? " ") }) })] }, key), focused, key);
1346
+ }
1347
+ // Single-line text family: text|email|password|number|date|time|
1348
+ // datetime-local (+ any unknown → text). Editable `<TextInput>` ONLY
1349
+ // when focused on a TTY; otherwise (and on the pure NO_CTX/non-TTY
1350
+ // path) the Phase-2 static box, byte-identical when no draft exists.
1351
+ const current = dval ?? node.value ?? "";
1352
+ if (rctx.interactive && focused && fk != null) {
1353
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [node.label ? _jsx(Text, { dimColor: true, children: node.label }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(TextInput, { focus: true, value: current, placeholder: node.placeholder ?? "", mask: it === "password" ? "•" : undefined, onChange: (v) => rctx.onFieldChange?.(fk, v), onSubmit: () => rctx.onFieldSubmit?.(node) }, "input") })] }, key), focused, key);
1354
+ }
1355
+ const hasValue = current !== "";
1356
+ const display = it === "password"
1357
+ ? hasValue
1358
+ ? "•".repeat(current.length)
1359
+ : (node.placeholder ?? "")
1360
+ : hasValue
1361
+ ? current
1362
+ : (node.placeholder ?? "");
1363
+ return this.focusWrap(_jsxs(Box, { flexDirection: "column", children: [node.label ? _jsx(Text, { dimColor: true, children: node.label }) : null, _jsx(Box, { borderStyle: "single", paddingX: 1, minWidth: 20, children: _jsx(Text, { dimColor: !hasValue, children: display }) })] }, key), focused, key);
1364
+ }
1365
+ case "modal": {
1366
+ const sp = this.spacing(density);
1367
+ // size → box width (cosmetic; fullscreen = full available width).
1368
+ const boxWidth = node.size === "narrow"
1369
+ ? 40
1370
+ : node.size === "wide"
1371
+ ? 90
1372
+ : node.size === "fullscreen"
1373
+ ? "100%"
1374
+ : 60; // medium (default)
1375
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: Math.max(1, sp.pad), paddingY: sp.gap ? 1 : 0, gap: sp.gap, width: boxWidth, children: [node.title ? (_jsx(Box, { children: _jsx(Text, { bold: true, children: node.title }) })) : null, node.dismissAction ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "✕ (Esc to close)" }) })) : null, _jsx(Box, { flexDirection: "column", gap: sp.gap, children: (node.children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, inherited, rctx)) }), node.footer && node.footer.length > 0 ? (_jsx(Box, { flexDirection: "row", gap: 1, children: node.footer.map((c, i) => this.renderNode(c, this.keyOf(c, i), density, inherited, rctx)) })) : null] }, key));
1376
+ }
1377
+ case "table": {
1378
+ // Phase 5d. Serves BOTH the static (renderTree/non-TTY/unit) and
1379
+ // interactive paths — interactive only adds the focus tint + mounts
1380
+ // the focused filter <TextInput>. DISCIPLINE: a flex-row of
1381
+ // independent fixed-width <Box> cells (the standard Ink table) is
1382
+ // SAFE — distinct from the Phase-5a bug (a styled <Text> nested INSIDE
1383
+ // one <Text>). Every cell is ONE flat <Text>; a link cell is OSC 8 as
1384
+ // the SOLE child of its own width-bounded Box (the Phase-1
1385
+ // string-width over-count, contained to that cell). Focus is a
1386
+ // whole-cell colour (cyan) — NOT focusWrap (its external "▸ " caret
1387
+ // would break fixed-column alignment); same approach as
1388
+ // MultiSelectInput's highlight.
1389
+ const t = node;
1390
+ const cols = t.columns ?? [];
1391
+ const rows = t.rows ?? [];
1392
+ const canSort = !!t.sortAction;
1393
+ const canFilter = !!t.filterAction;
1394
+ const anyFilter = canFilter && cols.some((c) => c.filterable);
1395
+ const MINW = 3, MAXW = 40, MINFILTER = 8, IND = 2;
1396
+ const cellText = (c, r) => {
1397
+ const v = r.cells?.[c.key] ?? "";
1398
+ // Measure the LABEL, never the OSC/href (Phase-1 over-count).
1399
+ return c.linkLabel && v ? c.linkLabel : v;
1400
+ };
1401
+ const widths = new Map();
1402
+ for (const c of cols) {
1403
+ let w = dispWidth(c.label) +
1404
+ ((canSort && c.sortable) || t.sortColumn === c.key ? IND : 0);
1405
+ for (const r of rows)
1406
+ w = Math.max(w, dispWidth(cellText(c, r)));
1407
+ if (canFilter && c.filterable)
1408
+ w = Math.max(w, MINFILTER);
1409
+ widths.set(c.key, Math.max(MINW, Math.min(MAXW, w)));
1410
+ }
1411
+ const w = (c) => widths.get(c.key) ?? MINW;
1412
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { flexDirection: "row", gap: 1, children: cols.map((c, ci) => {
1413
+ const sortable = canSort && !!c.sortable;
1414
+ const ind = t.sortColumn === c.key
1415
+ ? t.sortDirection === "desc"
1416
+ ? " ▼"
1417
+ : " ▲"
1418
+ : "";
1419
+ const hk = sortable ? rctx.focusKey(c) : undefined;
1420
+ const hfoc = hk != null && hk === rctx.focusedKey;
1421
+ return (_jsx(Box, { width: w(c), children: _jsx(Text, { bold: true, color: hfoc ? "cyan" : undefined, wrap: "truncate-end", children: c.label + ind }) }, c.key ?? ci));
1422
+ }) }), anyFilter ? (_jsx(Box, { flexDirection: "row", gap: 1, children: cols.map((c, ci) => {
1423
+ if (!c.filterable)
1424
+ return (_jsx(Box, { width: w(c), children: _jsx(Text, { children: " " }) }, c.key ?? ci));
1425
+ const fk = rctx.focusKey(filterIdent(c));
1426
+ const ffoc = fk != null && fk === rctx.focusedKey;
1427
+ const cur = (fk != null ? rctx.draft(fk) : undefined) ??
1428
+ c.filterValue ??
1429
+ "";
1430
+ return (_jsx(Box, { width: w(c), borderStyle: "single", children: rctx.interactive && ffoc && fk != null ? (_jsx(TableFilterInput, { focus: true, value: cur, onChange: (v) => rctx.onFieldChange?.(fk, v), onSubmit: () => rctx.onTableFilter?.(t, c) }, "input")) : (_jsx(Text, { dimColor: cur === "", children: cur === "" ? "filter…" : cur })) }, c.key ?? ci));
1431
+ }) })) : null, rows.map((r, ri) => {
1432
+ const tint = this.listItemStyle(r.variant).child;
1433
+ const rk = r.action ? rctx.focusKey(r) : undefined;
1434
+ const rfoc = rk != null && rk === rctx.focusedKey;
1435
+ return (_jsx(Box, { flexDirection: "row", gap: 1, children: cols.map((c, ci) => {
1436
+ const v = r.cells?.[c.key] ?? "";
1437
+ if (c.linkLabel && v) {
1438
+ // Link cell. A standalone `link` node emits OSC 8, but a
1439
+ // table cell is WIDTH-BOUNDED for column alignment, and
1440
+ // OSC 8 here would be truncate-mangled — string-width
1441
+ // over-counts the escape, so `wrap:"truncate-end"` at a
1442
+ // fixed column width corrupts it (the Phase-1/5a width
1443
+ // landmine is fundamentally incompatible with a fixed
1444
+ // column width). So show the linkLabel as underlined
1445
+ // text — information-honest and aligned; standalone
1446
+ // `link` nodes keep full OSC 8. (TUI-NOTES Phase 5d.)
1447
+ return (_jsx(Box, { width: w(c), children: _jsx(Text, { wrap: "truncate-end", underline: true, color: rfoc ? "cyan" : tint?.color, children: c.linkLabel }) }, c.key ?? ci));
1448
+ }
1449
+ return (_jsx(Box, { width: w(c), children: _jsx(Text, { wrap: "truncate-end", color: rfoc ? "cyan" : tint?.color, bold: tint?.bold, dimColor: tint?.dim, children: v === "" ? " " : v }) }, c.key ?? ci));
1450
+ }) }, r.id ?? ri));
1451
+ }), rows.length === 0 ? _jsx(Text, { dimColor: true, children: "(no rows)" }) : null] }, key));
1452
+ }
1453
+ default:
1454
+ // field(file) is handled in the field case; any unknown node type
1455
+ // (or a still-deferred one) → fail-loud, never blank.
1456
+ return this.unsupported(node.type, key);
1457
+ }
1458
+ }
1459
+ /** Resolves when the Ink app unmounts; immediate if nothing was rendered. */
1460
+ async waitUntilExit() {
1461
+ if (!this.instance)
1462
+ return;
1463
+ try {
1464
+ await this.instance.waitUntilExit();
1465
+ }
1466
+ catch {
1467
+ // Ink rejects waitUntilExit on some exit paths; treat as exited.
1468
+ }
1469
+ }
1470
+ /** Idempotent terminal restore — unmount restores cursor/raw mode. */
1471
+ dispose() {
1472
+ if (this.disposed)
1473
+ return;
1474
+ this.disposed = true;
1475
+ this.instance?.unmount();
1476
+ }
1477
+ }