@ashley-shrok/viewmodel-shell 0.5.0 → 0.7.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.
package/dist/tui.js CHANGED
@@ -1,1165 +1,286 @@
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";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
7
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
3
  import { homedir } from "node:os";
9
4
  import { join } from "node:path";
10
- const NO_CTX = {
11
- focusedKey: null,
12
- copiedKey: null,
13
- focusKey: () => undefined,
14
- interactive: false,
15
- fill: false,
16
- draft: () => undefined,
17
- };
18
- /** OSC 52 clipboard write — the terminal-native analog of the clipboard API.
19
- * Works over SSH (no local clipboard dependency). Exported for a direct,
20
- * deterministic unit test of the byte format. */
21
- export function osc52(text) {
22
- return `\x1b]52;c;${Buffer.from(String(text), "utf8").toString("base64")}\x07`;
23
- }
24
- /** Hand a URL to a real browser — the terminal analog of a redirect. Zero-dep
25
- * (node:child_process). Opener order: $BROWSER platform default. Detached +
26
- * unref so a launched browser can never hold or block the Node event loop
27
- * (teardown discipline). Returns false only if spawn threw *synchronously*
28
- * (no opener attempted); an async spawn failure (e.g. ENOENT — no xdg-open)
29
- * invokes `onSpawnError`. Callers map BOTH false and onSpawnError to the loud
30
- * interstitial, so a redirect is never silently lost. Exported so a unit test
31
- * can drive it with a stubbed node:child_process. */
32
- export function openExternal(url, onSpawnError) {
33
- const br = process.env.BROWSER;
34
- const [cmd, args] = br && br.trim()
35
- ? [br, [url]]
36
- : process.platform === "darwin"
37
- ? ["open", [url]]
38
- : process.platform === "win32"
39
- ? ["cmd", ["/c", "start", "", url]]
40
- : ["xdg-open", [url]];
41
- try {
42
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
43
- child.once("error", () => onSpawnError?.());
44
- child.unref();
45
- return true;
46
- }
47
- catch {
48
- return false;
49
- }
50
- }
51
- export function classify(url, fromEndpoint) {
52
- const u = (url ?? "").trim();
53
- if (!u)
54
- return { kind: "invalid", url };
55
- let abs;
56
- try {
57
- abs = new URL(u); // absolute?
5
+ import { spawn } from "node:child_process";
6
+ export class TuiAdapter {
7
+ renderer = null;
8
+ root = null;
9
+ pending = null;
10
+ initPromise = null;
11
+ disposed = false;
12
+ viewport;
13
+ sidebarFraction;
14
+ sessionStore = new Map();
15
+ // Focus model owned by the adapter (not by React hooks) so the static
16
+ // conformance walker can invoke components without a reconciler.
17
+ focusedPaneIndex = 0;
18
+ lastPaneCount = 0;
19
+ // B3 field state owned by the adapter. fieldValues is the live edit
20
+ // state (mutated by <input> onInput callbacks); fieldWireValues tracks
21
+ // the last-seen wire value per field so we can detect server intent
22
+ // changes (the AGENTS.md draft-preservation contract: user edits survive
23
+ // re-renders UNLESS the server explicitly sets a new value).
24
+ fieldValues = new Map();
25
+ fieldWireValues = new Map();
26
+ // B4 copy-button feedback state. `copiedKey` is set to the node.text
27
+ // string of the most recently activated copy-button; reverts to null
28
+ // after 1500ms. CopyButtonView checks ctx.copiedKey === node.text to
29
+ // decide whether to render the copiedLabel. Two buttons with the same
30
+ // copy text share this state (both flash "Copied!" together) — that's
31
+ // fine UX-wise and avoids needing per-button unique keys.
32
+ copiedKey = null;
33
+ copiedTimer = null;
34
+ constructor(opts) {
35
+ this.viewport = opts?.viewport ?? "fill";
36
+ const f = opts?.sidebarFraction ?? 1 / 3;
37
+ this.sidebarFraction = Math.min(0.6, Math.max(0.15, f));
58
38
  }
59
- catch {
39
+ // ── Field state plumbing (B3) ─────────────────────────────────────────────
40
+ // These are exposed on RCtx via the App's prop wiring so renderers can
41
+ // read/write field state without holding a reference to the adapter.
42
+ // setFieldValue is invoked from <input>/<textarea>/<select> change callbacks
43
+ // — it MUST NOT trigger a re-render (each keystroke would flicker). The map
44
+ // is only read on submit and on the next external re-render (server push).
45
+ setFieldValue = (name, value) => {
46
+ this.fieldValues.set(name, value);
47
+ };
48
+ /** Resolve a field's display value at render time. The draft-preservation
49
+ * contract (AGENTS.md) is "user edits survive re-renders UNLESS the server
50
+ * explicitly sets a new value for that field." Three states:
51
+ * 1. First time we see this field name — initialize the wire baseline
52
+ * WITHOUT clobbering any pre-existing edit value (matters when a test
53
+ * primes fieldValues via setFieldValue before the first render).
54
+ * 2. Wire value unchanged since last render — preserve the edit value.
55
+ * 3. Wire value differs from the prior wire baseline — server intent
56
+ * change → reset the edit value to match. */
57
+ // ── Copy-button state plumbing (B4) ───────────────────────────────────────
58
+ // copy() writes the text to the system clipboard via OSC-52 (escape
59
+ // sequence the terminal forwards to the OS), sets copiedKey, re-renders
60
+ // so the button label swaps to copiedLabel, and schedules a 1500ms timer
61
+ // that clears copiedKey + re-renders again. The clipboard write happens
62
+ // even in conformance/test paths where the renderer isn't initialized
63
+ // (Bun-free environments) — the write goes to process.stdout, which is
64
+ // always available, and the re-render flush is a no-op if the renderer
65
+ // isn't up.
66
+ //
67
+ // OSC-52 byte form is the SAME as the 0.4.8 link OSC-8 fix used:
68
+ // ESC ] 52 ; c ; <base64> BEL. ESC = \x1b, BEL = \x07. Terminals without
69
+ // OSC-52 support ignore the escape (no clipboard write, but the visual
70
+ // "Copied!" feedback still fires, which is honest behavior).
71
+ copy = (text) => {
72
+ const b64 = Buffer.from(text, "utf8").toString("base64");
73
+ const seq = `\x1b]52;c;${b64}\x07`;
60
74
  try {
61
- // relative → resolve against the current endpoint ⇒ same-origin.
62
- return { kind: "same-origin", endpoint: new URL(u, fromEndpoint).toString() };
63
- }
64
- catch {
65
- return { kind: "invalid", url };
75
+ process.stdout.write(seq);
66
76
  }
67
- }
68
- try {
69
- return abs.origin === new URL(fromEndpoint).origin
70
- ? { kind: "same-origin", endpoint: abs.toString() }
71
- : { kind: "different-origin", url: abs.toString() };
72
- }
73
- catch {
74
- return { kind: "invalid", url };
75
- }
76
- }
77
- const isTruthyFormValue = (v) => !!v && v !== "false" && v !== "0";
78
- /** A per-column-filter identity. A `TableColumn` object is reused for the
79
- * sortable header's focus mapping, so its filter input needs a DISTINCT,
80
- * stable-within-a-render identity for `map`/`rctx.focusKey`. WeakMap keyed by
81
- * the column object → the same sentinel in collectFocusables and the renderer
82
- * of one render pass; a fresh column object each server re-render makes a new
83
- * sentinel (focus continuity is by key string + reconcile(), never object
84
- * identity — same as every other focusable). */
85
- const _filterIdent = new WeakMap();
86
- function filterIdent(col) {
87
- let o = _filterIdent.get(col);
88
- if (!o) {
89
- o = {};
90
- _filterIdent.set(col, o);
91
- }
92
- return o;
93
- }
94
- /** Terminal display width, Unicode-aware enough for table column alignment.
95
- * Strips CSI/OSC escapes (incl. OSC 8) so a measured cell never includes
96
- * hyperlink bytes — the Phase-1 string-width over-count, here avoided by not
97
- * depending on string-width at all (zero new deps). Cells are width-bounded
98
- * Boxes, so a misestimate of an exotic glyph is at worst cosmetic, never the
99
- * Phase-1/5a layout corruption. */
100
- function dispWidth(s) {
101
- const clean = s
102
- // eslint-disable-next-line no-control-regex
103
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
104
- // eslint-disable-next-line no-control-regex
105
- .replace(/\x1b\[[0-9;]*m/g, "");
106
- let w = 0;
107
- for (const ch of clean) {
108
- const cp = ch.codePointAt(0);
109
- if (cp === 0)
110
- continue;
111
- if ((cp >= 0x300 && cp <= 0x36f) ||
112
- cp === 0x200d ||
113
- (cp >= 0xfe00 && cp <= 0xfe0f))
114
- continue; // combining / ZWJ / variation selectors → width 0
115
- if ((cp >= 0x1100 && cp <= 0x115f) ||
116
- (cp >= 0x2e80 && cp <= 0xa4cf) ||
117
- (cp >= 0xac00 && cp <= 0xd7a3) ||
118
- (cp >= 0xf900 && cp <= 0xfaff) ||
119
- (cp >= 0xfe30 && cp <= 0xfe4f) ||
120
- (cp >= 0xff00 && cp <= 0xff60) ||
121
- (cp >= 0xffe0 && cp <= 0xffe6) ||
122
- (cp >= 0x1f300 && cp <= 0x1faff) ||
123
- (cp >= 0x20000 && cp <= 0x3fffd))
124
- w += 2; // wide (CJK / fullwidth / emoji)
125
- else
126
- w += 1;
127
- }
128
- return w;
129
- }
130
- /** Pure pre-pass: every focusable in tree order + an object→key map used by
131
- * the renderer to highlight the focused node. Same identity heuristic the
132
- * roadmap locks (id / name / action.name, else positional), made unique
133
- * deterministically so the ring is unambiguous and stable across renders. */
134
- function collectFocusables(vm) {
135
- const list = [];
136
- const map = new Map();
137
- const counts = new Map();
138
- const uniq = (base) => {
139
- const n = counts.get(base) ?? 0;
140
- counts.set(base, n + 1);
141
- return n === 0 ? base : `${base}#${n}`;
77
+ catch { /* stdout closed — nothing to do */ }
78
+ this.copiedKey = text;
79
+ if (this.copiedTimer != null)
80
+ clearTimeout(this.copiedTimer);
81
+ this.copiedTimer = setTimeout(() => {
82
+ this.copiedKey = null;
83
+ this.copiedTimer = null;
84
+ this.flushPending();
85
+ }, 1500);
86
+ this.flushPending();
142
87
  };
143
- const visit = (node, form) => {
144
- switch (node.type) {
145
- case "button": {
146
- const k = uniq(node.action?.name ?? node.label ?? "button");
147
- map.set(node, k);
148
- list.push({ key: k, kind: "button", node, form });
149
- return;
150
- }
151
- case "checkbox": {
152
- if (!node.action)
153
- return; // not actionable → not in the ring
154
- const k = uniq(node.name ?? node.action.name ?? "checkbox");
155
- map.set(node, k);
156
- list.push({ key: k, kind: "checkbox", node, form });
157
- return;
158
- }
159
- case "tabs": {
160
- for (const t of node.tabs ?? []) {
161
- const k = uniq(`${node.action?.name ?? "tabs"}:${t.value}`);
162
- map.set(t, k);
163
- list.push({ key: k, kind: "tabs-tab", node, form, tab: t });
164
- }
165
- return;
166
- }
167
- case "copy-button": {
168
- const k = uniq(node.label ?? "copy");
169
- map.set(node, k);
170
- list.push({ key: k, kind: "copy", node, form });
171
- return;
172
- }
173
- case "link": {
174
- if (!(node.href ?? "").trim())
175
- return; // blank href → plain text (P1)
176
- const k = uniq(node.href ?? node.label ?? "link");
177
- map.set(node, k);
178
- list.push({ key: k, kind: "link", node, form });
179
- return;
180
- }
181
- case "field": {
182
- const it = node.inputType;
183
- if (it === "hidden" || it === "file")
184
- return;
185
- // invisible (hidden) / deferred (file) → not focusable. textarea/code
186
- // focusable since Phase 5a (multi-line editor); select/select-multiple
187
- // focusable since Phase 5b (pickers).
188
- const k = uniq(node.name ?? "field");
189
- map.set(node, k);
190
- list.push({ key: k, kind: "field", node, form });
191
- return;
192
- }
193
- case "form": {
194
- for (const c of node.children ?? [])
195
- visit(c, node);
196
- const k = uniq(node.submitAction?.name ?? "submit");
197
- map.set(node, k); // the form node carries its synthetic submit's key
198
- list.push({ key: k, kind: "form-submit", node, form: node });
199
- return;
88
+ resolveFieldValue = (name, wireValue) => {
89
+ if (!this.fieldWireValues.has(name)) {
90
+ this.fieldWireValues.set(name, wireValue);
91
+ if (!this.fieldValues.has(name)) {
92
+ this.fieldValues.set(name, wireValue);
200
93
  }
201
- case "modal":
202
- // Walk body + footer so a modal-rooted call (the focus trap) yields
203
- // the modal's own focusables. Harmless on a whole-tree walk with no
204
- // modal present (there are none); the App roots this at the modal
205
- // when one is open so base focusables are excluded.
206
- for (const c of node.children ?? [])
207
- visit(c, form);
208
- for (const c of node.footer ?? [])
209
- visit(c, form);
210
- return;
211
- case "table": {
212
- // Phase 5d. Visual/tab order mirrors the browser DOM: sortable
213
- // headers L→R, then filterable filter inputs L→R, then action rows
214
- // T→B. Header maps off the `col` object; the filter input maps off a
215
- // DISTINCT per-column sentinel (filterIdent) so a sortable+filterable
216
- // column has two unambiguous focus targets. Keys via uniq() keep
217
- // global uniqueness across multiple tables (the established pattern).
218
- const t = node;
219
- const cols = t.columns ?? [];
220
- if (t.sortAction)
221
- for (const col of cols)
222
- if (col.sortable) {
223
- const k = uniq(`tbl-sort:${col.key}`);
224
- map.set(col, k);
225
- list.push({ key: k, kind: "table-sort", node: t, col, form });
226
- }
227
- if (t.filterAction)
228
- for (const col of cols)
229
- if (col.filterable) {
230
- const k = uniq(`tbl-filter:${col.key}`);
231
- map.set(filterIdent(col), k);
232
- list.push({ key: k, kind: "table-filter", node: t, col, form });
233
- }
234
- (t.rows ?? []).forEach((row, ri) => {
235
- if (row.action) {
236
- const k = uniq(`tbl-row:${row.id ?? ri}`);
237
- map.set(row, k);
238
- list.push({ key: k, kind: "table-row", node: t, row, form });
239
- }
240
- });
241
- return;
242
- }
243
- case "page":
244
- case "section":
245
- case "list":
246
- case "list-item":
247
- for (const c of node.children ?? [])
248
- visit(c, form);
249
- return;
250
- default: // text, stat-bar, progress → not focusable
251
- return;
94
+ return this.fieldValues.get(name) ?? wireValue;
252
95
  }
253
- };
254
- visit(vm);
255
- return { list, map };
256
- }
257
- /** Depth-first first `modal` in the tree. Single-modal is the framework's
258
- * implied contract; nested/multiple → first wins (documented). Render-only:
259
- * no wire change. */
260
- function findModal(node) {
261
- if (node.type === "modal")
262
- return node;
263
- const kids = node.children;
264
- if (kids)
265
- for (const c of kids) {
266
- const m = findModal(c);
267
- if (m)
268
- return m;
96
+ const lastWire = this.fieldWireValues.get(name);
97
+ if (lastWire !== wireValue) {
98
+ this.fieldValues.set(name, wireValue);
99
+ this.fieldWireValues.set(name, wireValue);
269
100
  }
270
- const footer = node.footer;
271
- if (footer)
272
- for (const c of footer) {
273
- const m = findModal(c);
274
- if (m)
275
- return m;
276
- }
277
- return undefined;
278
- }
279
- const serverValue = (f) => f.value ?? "";
280
- /** Collect a form's field values for submission. Mirrors BrowserAdapter:
281
- * deferred input types skipped, form-checkbox → "true"/"false", hidden
282
- * included. Values come from `resolve` (draft-aware on the interactive
283
- * path; server value otherwise — so untyped == Phase 2). */
284
- function collectForm(form, resolve = serverValue) {
285
- const out = {};
286
- const walk = (node) => {
287
- if (node.type === "field") {
288
- const f = node;
289
- const it = f.inputType;
290
- if (it === "file") {
291
- return; // deferred input type — not collected
292
- }
293
- // select → chosen value; select-multiple → comma-joined values
294
- // (AGENTS.md "Multi-select submits comma-joined"); both round-trip the
295
- // draft-aware `resolve` (Phase 5b), exactly like the text family.
296
- if (it === "checkbox")
297
- out[f.name] = isTruthyFormValue(resolve(f)) ? "true" : "false";
298
- else
299
- out[f.name] = resolve(f);
300
- return;
301
- }
302
- const kids = node.children;
303
- if (kids)
304
- for (const c of kids)
305
- walk(c);
101
+ return this.fieldValues.get(name) ?? wireValue;
306
102
  };
307
- for (const c of form.children ?? [])
308
- walk(c);
309
- return out;
310
- }
311
- function submitOf(form, resolve = serverValue) {
312
- return {
313
- name: form.submitAction.name,
314
- context: { ...(form.submitAction.context ?? {}), ...collectForm(form, resolve) },
315
- };
316
- }
317
- /** The single-line `field` input types that become an editable `<TextInput>`
318
- * when focused on a TTY. NOT: hidden (invisible), checkbox (toggle),
319
- * textarea/code (multi-line — handled by isEditableMultiLine + the
320
- * MultilineEditor), or the still-deferred tier (select/select-multiple/file).
321
- * Unknown types fall through to text — same fail-soft as the renderer. */
322
- function isEditableSingleLine(it) {
323
- return (it !== "hidden" &&
324
- it !== "checkbox" &&
325
- it !== "textarea" &&
326
- it !== "code" &&
327
- it !== "select" &&
328
- it !== "select-multiple" &&
329
- it !== "file");
330
- }
331
- /** Phase 5a: the multi-line `field` input types that become an editable
332
- * `<MultilineEditor>` when focused on a TTY. `code` is rendered identically
333
- * to `textarea` plus a dim language-hint label; literal-tab insertion is
334
- * intentionally deferred (Tab always traverses the focus ring — the locked
335
- * input-arbitration invariant). */
336
- function isEditableMultiLine(it) {
337
- return it === "textarea" || it === "code";
338
- }
339
- /** Phase 5b: the `field` input types that become an interactive picker when
340
- * focused on a TTY — `select` (single, via `ink-select-input`) and
341
- * `select-multiple` (multi, via the contained `MultiSelectInput`). Not a text
342
- * editor (so deliberately NOT in isEditableSingleLine); it joins the editing
343
- * gate so the focused picker owns Up/Down/Enter/Space while App keeps Tab/
344
- * Shift-Tab (ring) + Ctrl-C (teardown). */
345
- function isSelect(it) {
346
- return it === "select" || it === "select-multiple";
347
- }
348
- /** Reconcile the focused key against a (possibly new) focusable list — the
349
- * roadmap's continuity rule: keep it if still present; else clamp to the
350
- * prior index position; else first; else none. */
351
- function reconcile(keys, focusedKey, prev) {
352
- if (keys.length === 0)
353
- return null;
354
- if (focusedKey && keys.includes(focusedKey))
355
- return focusedKey;
356
- if (prev && prev.key) {
357
- const pi = prev.keys.indexOf(prev.key);
358
- if (pi >= 0)
359
- return keys[Math.min(pi, keys.length - 1)] ?? keys[0];
360
- }
361
- return keys[0];
362
- }
363
- /** Full-screen loud notice (failed/external/invalid redirect, storage I/O
364
- * failure). Pure render — NO useInput of its own: it is shown by swapping
365
- * App's rendered subtree, App's existing sole useInput stays mounted, so the
366
- * unconditional Ctrl-C → requestExit branch still quits and the Phase 0–3
367
- * teardown topology is unchanged (no new input hook). */
368
- function Interstitial({ msg }) {
369
- 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." }) })] }));
370
- }
371
- /**
372
- * Phase 5a contained multi-line editor for `textarea`/`code`. Built ONLY on
373
- * Ink's already-vetted primitives (useInput key-decode, Box, Text) — there is
374
- * no mature Ink-5/React-18-compatible multi-line lib (every option is pre-1.0
375
- * and either abandoned or forces a forbidden Ink 6/7 + React 19 bump), so a
376
- * contained editor is the only path respecting the locked toolkit + the
377
- * zero-blast-radius invariant. Zero new dependencies.
378
- *
379
- * Controlled (value/onChange — the App draft map is the single source of
380
- * truth, exactly like the single-line `<TextInput>`). Internal {row,col}
381
- * caret in useState, held in the stable component instance (key="input" under
382
- * the stable App root) so it survives the shell's instance.rerender() — the
383
- * Phase-3 caret-continuity mechanism, one level down.
384
- *
385
- * Input contract MIRRORS ink-text-input so the two-handler arbitration with
386
- * App's root useInput stays collision-free: this editor EARLY-RETURNS Ctrl-C
387
- * (App owns teardown → requestExit(130)) and Tab/Shift-Tab (App owns ring
388
- * traversal — the locked invariant; `code` is therefore NOT literal-tab
389
- * aware, by design), and owns char insert / Enter→newline / Backspace+Delete
390
- * / Left/Right (wrapping across lines) / Up/Down (clamped). A multi-char paste
391
- * burst is inserted verbatim; embedded CR/LF split lines (Phase-3 paste rule).
392
- * Form submission stays the focus ring's submit button (the user Tabs out) —
393
- * Enter never submits a multi-line field; field `action`/Enter-dispatch is
394
- * N/A for multi-line.
395
- */
396
- function MultilineEditor(props) {
397
- const split = (s) => (s.length === 0 ? [""] : s.split("\n"));
398
- // Caret starts at the END of the initial value (focusing a pre-filled field
399
- // puts the caret after the text — mirrors ink-text-input's UX). Lazy
400
- // initializer runs ONCE at mount, never on the shell's rerenders, so caret
401
- // continuity holds (the Phase-3 mechanism, one level down).
402
- const [cursor, setCursor] = useState(() => {
403
- const ls = split(props.value);
404
- return { row: ls.length - 1, col: (ls[ls.length - 1] ?? "").length };
405
- });
406
- const lines = split(props.value);
407
- // Clamp for render (value may have shrunk via server-wins before the effect
408
- // below re-syncs the stored cursor).
409
- // Re-clamp the stored caret when the controlled value shrinks (mirrors
410
- // ink-text-input's clamp effect). Guarded → no extra render when unchanged.
411
- useEffect(() => {
412
- const ls = split(props.value);
413
- setCursor((c) => {
414
- const r = Math.min(c.row, ls.length - 1);
415
- const col = Math.min(c.col, (ls[r] ?? "").length);
416
- return r === c.row && col === c.col ? c : { row: r, col };
417
- });
418
- }, [props.value]);
419
- useInput((input, key) => {
420
- // App owns these — never consume them (Ink calls every useInput; no
421
- // bubbling, so a plain return cedes the key).
422
- if (key.ctrl && input === "c")
423
- return; // → App requestExit(130)
424
- if (key.tab)
425
- return; // Tab / Shift-Tab → App ring traversal (locked)
426
- const cur = split(props.value);
427
- const r = Math.min(cursor.row, cur.length - 1);
428
- const c = Math.min(cursor.col, (cur[r] ?? "").length);
429
- const commit = (next, nr, nc) => {
430
- props.onChange(next.join("\n"));
431
- setCursor({ row: nr, col: nc });
432
- };
433
- if (key.return) {
434
- const line = cur[r] ?? "";
435
- const next = [
436
- ...cur.slice(0, r),
437
- line.slice(0, c),
438
- line.slice(c),
439
- ...cur.slice(r + 1),
440
- ];
441
- commit(next, r + 1, 0);
442
- return;
443
- }
444
- if (key.leftArrow) {
445
- if (c > 0)
446
- setCursor({ row: r, col: c - 1 });
447
- else if (r > 0)
448
- setCursor({ row: r - 1, col: (cur[r - 1] ?? "").length });
449
- return;
450
- }
451
- if (key.rightArrow) {
452
- if (c < (cur[r] ?? "").length)
453
- setCursor({ row: r, col: c + 1 });
454
- else if (r < cur.length - 1)
455
- setCursor({ row: r + 1, col: 0 });
456
- return;
457
- }
458
- if (key.upArrow) {
459
- if (r > 0)
460
- setCursor({
461
- row: r - 1,
462
- col: Math.min(c, (cur[r - 1] ?? "").length),
463
- });
464
- return;
465
- }
466
- if (key.downArrow) {
467
- if (r < cur.length - 1)
468
- setCursor({
469
- row: r + 1,
470
- col: Math.min(c, (cur[r + 1] ?? "").length),
471
- });
103
+ // ── Adapter.render ────────────────────────────────────────────────────────
104
+ // The shell calls this synchronously and does NOT await. OpenTUI's
105
+ // createCliRenderer is async, so we init lazily: the first render() kicks
106
+ // off initialization; subsequent renders are sync. Late renders that
107
+ // arrive before init resolves are coalesced into `pending` (last-write-wins).
108
+ render(vm, onAction) {
109
+ if (this.disposed)
472
110
  return;
473
- }
474
- if (key.backspace || key.delete) {
475
- // Backspace semantics (delete the char BEFORE the caret) for both —
476
- // matches ink-text-input, so single- and multi-line editing feel
477
- // identical. Forward-delete is intentionally deferred.
478
- if (c > 0) {
479
- const line = cur[r] ?? "";
480
- const next = [...cur];
481
- next[r] = line.slice(0, c - 1) + line.slice(c);
482
- commit(next, r, c - 1);
483
- }
484
- else if (r > 0) {
485
- const prev = cur[r - 1] ?? "";
486
- const next = [
487
- ...cur.slice(0, r - 1),
488
- prev + (cur[r] ?? ""),
489
- ...cur.slice(r + 1),
490
- ];
491
- commit(next, r - 1, prev.length);
492
- }
111
+ this.pending = { vm, onAction };
112
+ if (this.renderer == null) {
113
+ if (this.initPromise == null)
114
+ this.initPromise = this.init();
493
115
  return;
494
116
  }
495
- if (input && !key.ctrl && !key.meta) {
496
- // Printable / paste burst. Normalize CR(LF) so a pasted multi-line
497
- // block splits cleanly.
498
- const text = input.replace(/\r\n?/g, "\n");
499
- const line = cur[r] ?? "";
500
- const parts = (line.slice(0, c) + text + line.slice(c)).split("\n");
501
- const next = [...cur.slice(0, r), ...parts, ...cur.slice(r + 1)];
502
- const last = parts[parts.length - 1];
503
- commit(next, r + parts.length - 1, parts.length === 1 ? c + text.length : last.length);
504
- }
505
- }, { isActive: props.focus });
506
- const empty = props.value.length === 0;
507
- if (empty && !props.focus) {
508
- return (_jsx(Text, { dimColor: true, children: props.placeholder ? props.placeholder : " " }));
117
+ this.flushPending();
509
118
  }
510
- return (_jsx(Box, { flexDirection: "column", children: lines.map((ln, i) => (
511
- // One flat <Text> per line. A nested in-text caret element corrupts
512
- // Ink/Yoga width measurement inside the live focusWrap + bordered-box
513
- // tree (the Phase-1 mixed-<Text> width lesson — verified: split caret
514
- // wraps "hello" into per-char lines; one flat <Text> renders clean).
515
- // Focus is signalled by focusWrap's leading ▸; the caret is tracked
516
- // internally for edit logic. Rendering a caret glyph is a documented
517
- // deferred polish (would also fragment the value-substring tests).
518
- _jsx(Text, { children: ln === "" ? " " : ln }, i))) }));
519
- }
520
- /**
521
- * Phase 5b contained multi-select picker for `select-multiple`. Built ONLY on
522
- * Ink's already-vetted primitives — `ink-select-input` (the locked, mature
523
- * Ink-5/React-18 lib used for single `select`) is single-select only, and no
524
- * mature Ink-5/React-18 multi-select lib documents the Tab/Shift-Tab/Ctrl-C
525
- * pass-through this codebase's input-arbitration depends on (`ink-multi-select`
526
- * is dead — ink^3/react^16; `@inkjs/ui` MultiSelect's key handling is
527
- * undocumented). Same precedent + rationale as Phase-5a's `MultilineEditor`:
528
- * contained component, zero new deps, exact known keyboard contract.
529
- *
530
- * CONTROLLED by `value` (comma-joined, the wire shape — AGENTS.md "Multi-select
531
- * submits comma-joined"; Showcase emits `value:"a,c"`). The selected SET is
532
- * derived from `value` every render, so it is server-authoritative by
533
- * construction: when App drops the select draft on a server re-render (selects
534
- * are excluded from draft preservation — AGENTS.md), `value` becomes the server
535
- * value and the rendered selection follows. Only the highlight index is
536
- * internal (a cursor, not a value — fine to persist across re-renders, like
537
- * MultilineEditor's caret).
538
- *
539
- * Input contract MIRRORS MultilineEditor/ink-text-input: EARLY-RETURNS Ctrl-C
540
- * (App owns teardown → requestExit(130)) and Tab/Shift-Tab (App owns ring
541
- * traversal — locked); owns Up/Down (move highlight) and Space (toggle the
542
- * highlighted option). Enter is inert here (App's editing branch also returns
543
- * on Enter) — submission stays the ring's submit button (the user Tabs out),
544
- * exactly like a browser multi-select element.
545
- */
546
- function MultiSelectInput(props) {
547
- const parse = (s) => s
548
- .split(",")
549
- .map((x) => x.trim())
550
- .filter((x) => x.length > 0);
551
- const opts = props.options;
552
- const [hi, setHi] = useState(0);
553
- const selected = new Set(parse(props.value));
554
- useInput((input, key) => {
555
- // App owns these — never consume them (Ink calls every useInput; no
556
- // bubbling, so a plain return cedes the key).
557
- if (key.ctrl && input === "c")
558
- return; // → App requestExit(130)
559
- if (key.tab)
560
- return; // Tab / Shift-Tab → App ring traversal (locked)
561
- if (key.upArrow) {
562
- setHi((h) => (opts.length ? (h - 1 + opts.length) % opts.length : 0));
563
- return;
564
- }
565
- if (key.downArrow) {
566
- setHi((h) => (opts.length ? (h + 1) % opts.length : 0));
567
- return;
568
- }
569
- if (input === " ") {
570
- const o = opts[hi];
571
- if (!o)
572
- return;
573
- const next = new Set(parse(props.value));
574
- if (next.has(o.value))
575
- next.delete(o.value);
576
- else
577
- next.add(o.value);
578
- // Preserve option order in the comma-joined wire value.
579
- const joined = opts
580
- .filter((x) => next.has(x.value))
581
- .map((x) => x.value)
582
- .join(",");
583
- props.onChange(joined);
584
- return;
585
- }
586
- // Enter / anything else: inert (App's editing branch also returns).
587
- }, { isActive: props.focus });
588
- if (opts.length === 0)
589
- return _jsx(Text, { dimColor: true, children: " " });
590
- return (_jsx(Box, { flexDirection: "column", children: opts.map((o, i) => {
591
- const on = selected.has(o.value);
592
- const cur = i === hi;
593
- // ONE flat <Text> per row — a nested styled span mid-string corrupts
594
- // Ink/Yoga width measurement inside the live focusWrap+border tree
595
- // (the Phase-1/5a mixed-<Text> width lesson).
596
- return (_jsx(Text, { color: cur ? "cyan" : undefined, children: (cur ? "▸ " : " ") + (on ? "[x] " : "[ ] ") + o.label }, o.value));
597
- }) }));
598
- }
599
- /**
600
- * Phase 5d — table per-column filter editor. A thin wrapper over
601
- * `ink-text-input` (already a dependency since Phase 3 — ZERO new deps),
602
- * mounted ONLY when its filter cell is focused on a TTY (the select/multiline
603
- * precedent). ink-text-input@6 early-returns Tab/Shift-Tab/Up/Down/Ctrl-C, so
604
- * App keeps ring traversal + teardown; it owns char/Backspace/Left/Right and
605
- * fires `onSubmit` on Enter → the filterAction dispatch (browser.ts parity).
606
- * A focused table-filter also joins App's `editing` gate (kind
607
- * "table-filter") so ring-mode arrow handling can't collide with the editor's
608
- * cursor — the two input handlers never fight.
609
- */
610
- function TableFilterInput(props) {
611
- return (_jsx(TextInput, { focus: props.focus, value: props.value, placeholder: "filter\u2026", onChange: props.onChange, onSubmit: props.onSubmit }));
612
- }
613
- /** The stateful root. Mounted ONCE; the shell's poll/dispatch re-renders
614
- * reconcile the SAME component instance (React preserves its focus state) —
615
- * this is why a stable root component, not a bare tree. */
616
- function App(props) {
617
- const { vm, renderWith } = props;
618
- const { isRawModeSupported } = useStdin();
619
- const { write, stdout } = useStdout();
620
- // `=== true` is LOAD-BEARING, not defensive. Ink's App.isRawModeSupported()
621
- // returns `this.props.stdin.isTTY`, which is `true` on a TTY but `undefined`
622
- // (never `false`) on a non-TTY stdin (pipe / </dev/null / agent / CI). Ink's
623
- // useInput skips raw mode ONLY when `options.isActive === false` (strict).
624
- // So passing the raw `undefined` as isActive does NOT skip → Ink calls
625
- // setRawMode → throws "Raw mode is not supported" and dumps a react error
626
- // frame on the non-TTY path. Coercing to a real boolean makes the gate
627
- // `{ isActive: false }`, which Ink honors → clean static render. (A TTY
628
- // gives `true`; ink-testing-library gives `true` → tests unchanged.)
629
- const interactive = isRawModeSupported === true;
630
- // Viewport fill (0.4.5). On a real interactive terminal, occupy the whole
631
- // window so layout presets — especially `sidebar`'s flexGrow main pane —
632
- // can expand: the terminal analog of BrowserAdapter filling the viewport
633
- // via CSS. Gated on the REAL process TTYs, NOT Ink's isRawModeSupported
634
- // (which ink-testing-library forces `true`): under vitest
635
- // `process.stdout.isTTY` is false ⇒ no wrap ⇒ every existing App and
636
- // conformance frame stays byte-identical by construction (and the pure
637
- // `renderTree` path never reaches here). Opt out with
638
- // `new TuiAdapter({ viewport: "content" })`.
639
- const realTTY = process.stdout.isTTY === true && process.stdin.isTTY === true;
640
- const fillViewport = realTTY && props.viewport !== "content";
641
- const [vp, setVp] = useState(() => ({
642
- cols: stdout?.columns,
643
- rows: stdout?.rows,
644
- }));
645
- useEffect(() => {
646
- if (!fillViewport || !stdout)
647
- return;
648
- const onResize = () => setVp({ cols: stdout.columns, rows: stdout.rows });
649
- onResize(); // sync to the live size on (re)mount
650
- stdout.on("resize", onResize);
651
- return () => {
652
- stdout.off("resize", onResize);
653
- };
654
- }, [fillViewport, stdout]);
655
- const [focusedKey, setFocusedKey] = useState(null);
656
- const [copiedKey, setCopiedKey] = useState(null);
657
- // User-typed field drafts, keyed by focus key. Mirrors BrowserAdapter's
658
- // draft-preservation: a draft survives the shell's poll/dispatch
659
- // re-renders unless the field disappears or the SERVER changes that
660
- // field's authoritative value (then the server wins).
661
- const [draft, setDraft] = useState({});
662
- // Phase 5c: a modal traps focus. Detect it (interactive only — non-TTY/unit
663
- // renders the whole tree inline, preserving the Phase-1 non-TTY contract +
664
- // deterministic static tests) and root the focus ring at the modal subtree
665
- // so base focusables are excluded while it is open.
666
- const modal = interactive ? findModal(vm) : undefined;
667
- const { list, map } = collectFocusables(modal ?? vm);
668
- const keys = list.map((d) => d.key);
669
- const prevRef = useRef(undefined);
670
- const effective = interactive
671
- ? reconcile(keys, focusedKey, prevRef.current)
672
- : null;
673
- // Latest-value refs so the (single, stable) input handler never goes stale.
674
- const onActionRef = useRef(props.onAction);
675
- onActionRef.current = props.onAction;
676
- const requestExitRef = useRef(props.requestExit);
677
- requestExitRef.current = props.requestExit;
678
- // Same single useInput — NOT a new hook. While an interstitial is shown the
679
- // ring is inert (only Ctrl-C acts) so a stray key can't dispatch into the
680
- // shell behind a terminal notice. Mirrors the existing editingRef guard.
681
- const interstitialActiveRef = useRef(props.interstitial != null);
682
- interstitialActiveRef.current = props.interstitial != null;
683
- // Phase 5c — same single useInput (NOT a new hook): a ref-gated Esc branch,
684
- // exactly mirroring interstitialActiveRef. tui-cli.ts stays unchanged ⇒
685
- // teardown topology identical to Phase 4 (safe by construction; PTY-verified).
686
- const modalActiveRef = useRef(modal != null);
687
- modalActiveRef.current = modal != null;
688
- const dismissActionRef = useRef(modal?.dismissAction ?? null);
689
- dismissActionRef.current = modal?.dismissAction ?? null;
690
- const listRef = useRef(list);
691
- listRef.current = list;
692
- const effectiveRef = useRef(effective);
693
- effectiveRef.current = effective;
694
- const writeRef = useRef(write);
695
- writeRef.current = write;
696
- const copyTimer = useRef(undefined);
697
- // Per-render snapshot of every DRAFTABLE focusable's server value, vs the
698
- // previous render's snapshot — the change detector for "server wins".
699
- // Draftable = `field` (Phase 3/5a/5b) + Phase-5d `table-filter` inputs
700
- // (server value = the column's `filterValue`). The field path is
701
- // byte-identical to Phase 5b — table-filter rows are purely ADDITIVE, so
702
- // every existing field/draft test is unaffected.
703
- const draftableDescs = list.filter((d) => d.kind === "field" || d.kind === "table-filter");
704
- const serverValOf = (d) => d.kind === "table-filter"
705
- ? (d.col.filterValue ?? "")
706
- : (d.node.value ?? "");
707
- const fieldKeysNow = new Set(draftableDescs.map((d) => d.key));
708
- const fieldServerNow = {};
709
- for (const d of draftableDescs)
710
- fieldServerNow[d.key] = serverValOf(d);
711
- const prevServerRef = useRef({});
712
- // Phase 5b: focus keys of select/select-multiple fields. Selects are
713
- // EXCLUDED from draft preservation (AGENTS.md: can't distinguish
714
- // "server set this" from "user changed it") — so a select draft is
715
- // server-authoritative the moment a SERVER re-render arrives, even if that
716
- // field's value is unchanged (the inverse of the text draft-survival rule).
717
- const selectKeysNow = new Set(draftableDescs
718
- .filter((d) => d.kind === "field" && isSelect(d.node.inputType))
719
- .map((d) => d.key));
720
- // A server re-render is observable as a new `vm` object identity (the shell
721
- // passes a freshly-parsed vm each render(); a local setState re-render keeps
722
- // the SAME vm prop). First render: undefined → not a "server" render.
723
- const prevVmRef = useRef(undefined);
724
- const isServerRender = prevVmRef.current !== undefined && prevVmRef.current !== vm;
725
- /** Effective draft for a focus key: the typed value when it should still
726
- * win — i.e. there IS a draft, the field still exists, and the server
727
- * hasn't changed that field's value since we last saw it. Otherwise
728
- * undefined → caller falls back to the server value. */
729
- const draftFor = (k) => {
730
- if (!(k in draft))
731
- return undefined;
732
- if (!fieldKeysNow.has(k))
733
- return undefined; // field / table-filter gone
734
- if (prevServerRef.current[k] !== fieldServerNow[k])
735
- return undefined; // server changed → authoritative
736
- if (selectKeysNow.has(k) && isServerRender)
737
- return undefined; // selects: server-authoritative across ANY server re-render (AGENTS.md — excluded from draft preservation)
738
- return draft[k];
739
- };
740
- const resolve = (f) => {
741
- const k = map.get(f);
742
- const dv = k != null ? draftFor(k) : undefined;
743
- return dv ?? f.value ?? "";
744
- };
745
- const focusedDesc = list.find((d) => d.key === effective);
746
- const editing = interactive &&
747
- ((focusedDesc?.kind === "field" &&
748
- (isEditableSingleLine(focusedDesc.node.inputType) ||
749
- isEditableMultiLine(focusedDesc.node.inputType) ||
750
- isSelect(focusedDesc.node.inputType))) ||
751
- // Phase 5d: a focused table FILTER input is an editable <TextInput>; it
752
- // joins the editing gate so App cedes char/Left/Right/Enter to it and
753
- // keeps ONLY Tab/Shift-Tab (ring) + Ctrl-C (teardown). Without this,
754
- // ring-mode arrow handling would collide with the editor's cursor and
755
- // Down/Right would jump focus mid-type. The `field` disjunct above is
756
- // byte-identical → zero field/form behavior change. table-sort /
757
- // table-row stay ring-mode (Enter/Space → activate).
758
- focusedDesc?.kind === "table-filter");
759
- const editingRef = useRef(editing);
760
- editingRef.current = editing;
761
- useEffect(() => {
762
- if (effective !== focusedKey)
763
- setFocusedKey(effective);
764
- prevRef.current = { keys, key: effective };
765
- });
766
- // Prune stale drafts (field gone, or server changed its value) and record
767
- // this render's server snapshot for the next render's change detection.
768
- // Guarded so an unchanged draft map keeps its identity → no extra render.
769
- useEffect(() => {
770
- setDraft((prev) => {
771
- let changed = false;
772
- const next = {};
773
- for (const k of Object.keys(prev)) {
774
- const stale = !fieldKeysNow.has(k) ||
775
- prevServerRef.current[k] !== fieldServerNow[k] ||
776
- (selectKeysNow.has(k) && isServerRender); // selects never survive a server re-render
777
- if (stale) {
778
- changed = true;
779
- continue;
780
- }
781
- next[k] = prev[k];
782
- }
783
- return changed ? next : prev;
784
- });
785
- prevServerRef.current = fieldServerNow;
786
- prevVmRef.current = vm;
787
- });
788
- useEffect(() => () => {
789
- if (copyTimer.current)
790
- clearTimeout(copyTimer.current);
791
- }, []);
792
- const doCopy = (key, text) => {
119
+ async init() {
793
120
  try {
794
- writeRef.current(osc52(text));
795
- }
796
- catch {
797
- /* OSC 52 unsupported terminals: silent, like the browser clipboard */
798
- }
799
- setCopiedKey(key);
800
- if (copyTimer.current)
801
- clearTimeout(copyTimer.current);
802
- copyTimer.current = setTimeout(() => setCopiedKey(null), 1500);
803
- };
804
- /** Field Enter (its own action dispatch {[name]: current}; else submit
805
- * the enclosing form). `current` is the draft-aware resolved value. */
806
- const submitField = (d) => {
807
- const dispatch = onActionRef.current;
808
- const node = d.node;
809
- if (node.action) {
810
- const cur = draftFor(d.key) ?? node.value ?? "";
811
- dispatch({
812
- name: node.action.name,
813
- context: { ...(node.action.context ?? {}), [node.name]: cur },
814
- });
815
- }
816
- else if (d.form) {
817
- dispatch(submitOf(d.form, resolve));
818
- }
819
- };
820
- /** Space on a form-`checkbox` field → flip its draft boolean. */
821
- const toggleCheckboxDraft = (d) => {
822
- const node = d.node;
823
- const cur = draftFor(d.key) ?? node.value ?? "";
824
- const nextVal = isTruthyFormValue(cur) ? "false" : "true";
825
- setDraft((s) => ({ ...s, [d.key]: nextVal }));
826
- };
827
- const onFieldSubmit = (field) => {
828
- const k = map.get(field);
829
- const d = list.find((x) => x.key === k);
830
- if (d)
831
- submitField(d);
832
- };
833
- /** Phase 5d — a table per-column filter's Enter. Mirrors BrowserAdapter
834
- * (browser.ts): dispatch `filterAction` merged with { column, value,
835
- * filters }, where `filters` is EVERY filterable column's current text
836
- * (draft else server `filterValue`) and `value` is this column's. */
837
- const tableFilter = (table, col) => {
838
- const fa = table.filterAction;
839
- if (!fa)
840
- return;
841
- const textOf = (c) => {
842
- const fk = map.get(filterIdent(c));
843
- const dv = fk != null ? draftFor(fk) : undefined;
844
- return dv ?? c.filterValue ?? "";
845
- };
846
- const filters = {};
847
- for (const c of table.columns ?? [])
848
- if (c.filterable)
849
- filters[c.key] = textOf(c);
850
- onActionRef.current({
851
- name: fa.name,
852
- context: {
853
- ...(fa.context ?? {}),
854
- column: col.key,
855
- value: textOf(col),
856
- filters,
857
- },
858
- });
859
- };
860
- const activate = (d, trigger) => {
861
- const dispatch = onActionRef.current;
862
- switch (d.kind) {
863
- case "button":
864
- dispatch({
865
- name: d.node.action.name,
866
- context: { ...(d.node.action.context ?? {}) },
867
- });
868
- break;
869
- case "checkbox":
870
- if (d.node.action)
871
- dispatch({
872
- name: d.node.action.name,
873
- context: { ...(d.node.action.context ?? {}), checked: !d.node.checked },
874
- });
875
- break;
876
- case "tabs-tab":
877
- dispatch({
878
- name: d.node.action.name,
879
- context: { ...(d.node.action.context ?? {}), value: d.tab.value },
880
- });
881
- break;
882
- case "field": {
883
- const node = d.node;
884
- if (node.inputType === "checkbox") {
885
- if (trigger === "space")
886
- toggleCheckboxDraft(d);
887
- else
888
- submitField(d); // Enter on a form-checkbox → submit / its action
121
+ // Dynamic value imports — keeping these out of the module-level
122
+ // import graph means this file is loadable under Node (vitest, type
123
+ // checking, conformance walks), while the actual render path still
124
+ // requires Bun. See the top-of-file comment for the rationale.
125
+ const { createCliRenderer } = await import("@opentui/core");
126
+ const { createRoot } = await import("@opentui/react");
127
+ this.renderer = await createCliRenderer();
128
+ this.root = createRoot(this.renderer);
129
+ // Subscribe to keyboard events on the renderer for Tab focus cycling.
130
+ // OpenTUI's CliRenderer emits "key" events; we intercept Tab/Shift-Tab
131
+ // here (the focused pane's ScrollBox still receives every other key
132
+ // for its own scroll handling arrows, PageUp/Down, Home/End).
133
+ const r = this.renderer;
134
+ r.on("key", (e) => {
135
+ if (this.disposed)
136
+ return;
137
+ if (e.name === "tab") {
138
+ this.cycleFocus(!e.shift);
889
139
  }
890
- else {
891
- // Editable single-line fields are driven by ink-text-input's
892
- // onSubmit in editing mode and never reach here; this is a safety
893
- // net (e.g. a field somehow activated in ring mode) treat as submit.
894
- submitField(d);
140
+ else if (e.name === "return" || e.name === "enter") {
141
+ // B5 Enter activates the focused pane's primary actionable
142
+ // (first button/link/copy-button). Skipped when the focused pane
143
+ // has any FieldNode input widgets own Enter for form submit
144
+ // (FieldView's `<input onSubmit>` already handles that).
145
+ this.activatePane("enter");
895
146
  }
896
- break;
897
- }
898
- case "form-submit":
899
- dispatch(submitOf(d.node, resolve));
900
- break;
901
- case "copy":
902
- doCopy(d.key, d.node.text);
903
- break;
904
- case "link":
905
- doCopy(d.key, d.node.href);
906
- break;
907
- case "table-sort": {
908
- // browser.ts parity: toggle asc↔desc only when this column is the
909
- // current sortColumn AND was asc; otherwise default to asc.
910
- const t = d.node;
911
- const col = d.col;
912
- const sa = t.sortAction;
913
- if (!col || !sa)
914
- break;
915
- const isSorted = t.sortColumn === col.key;
916
- const nextDir = isSorted && t.sortDirection === "asc" ? "desc" : "asc";
917
- dispatch({
918
- name: sa.name,
919
- context: {
920
- ...(sa.context ?? {}),
921
- column: col.key,
922
- direction: nextDir,
923
- },
924
- });
925
- break;
926
- }
927
- case "table-row":
928
- // Verbatim, no merge — mirrors BrowserAdapter `on(rowAction)`.
929
- if (d.row?.action)
930
- dispatch(d.row.action);
931
- break;
932
- case "table-filter":
933
- // Editable: the focused <TextInput>'s onSubmit (→ rctx.onTableFilter)
934
- // owns the dispatch. Unreachable while editing (App returns before the
935
- // ring-activate line); a defensive no-op for any edge path.
936
- break;
147
+ else if (e.name === "space") {
148
+ // B5 — Space toggles the focused pane's first checkbox (the one
149
+ // with an action). Skipped when the pane has inputs (Space is a
150
+ // legitimate text character there).
151
+ this.activatePane("space");
152
+ }
153
+ });
154
+ this.flushPending();
937
155
  }
938
- };
939
- useInput((input, key) => {
940
- if (key.ctrl && input === "c") {
941
- requestExitRef.current(130);
942
- return;
156
+ catch (err) {
157
+ // No safe no-op here — surface to stderr so the user sees what broke
158
+ // (e.g. non-TTY environment, missing native binary, or Node-only
159
+ // invocation that slipped past the CLI's Bun guard).
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ try {
162
+ process.stderr.write(`vms-tui: renderer init failed: ${msg}\n`);
163
+ }
164
+ catch { /* nothing */ }
943
165
  }
944
- if (interstitialActiveRef.current)
945
- return; // notice up: only Ctrl-C acts
946
- if (modalActiveRef.current && key.escape) {
947
- // Esc dismisses a modal IFF it carries a dismissAction (AGENTS.md:
948
- // no dismissAction & no footer ⇒ non-dismissible — never synthesize a
949
- // close). Placed before the ring-empty + editing branches so a
950
- // text-only dismissible modal still closes, and Esc cancels even from
951
- // within a body field (ink-text-input/MultilineEditor don't consume
952
- // Esc). Non-Esc keys fall through to the trapped ring below.
953
- if (dismissActionRef.current)
954
- onActionRef.current(dismissActionRef.current);
166
+ }
167
+ flushPending() {
168
+ if (!this.pending || !this.root || this.disposed)
955
169
  return;
170
+ const { vm, onAction } = this.pending;
171
+ // Count panes on the current VM so Tab knows the cycle modulus + so we
172
+ // can clamp focusedPaneIndex if the tree shrank.
173
+ const paneCount = countPanes(vm);
174
+ if (paneCount === 0) {
175
+ this.focusedPaneIndex = 0;
956
176
  }
957
- const ring = listRef.current;
958
- if (ring.length === 0)
959
- return;
960
- let idx = ring.findIndex((d) => d.key === effectiveRef.current);
961
- if (idx < 0)
962
- idx = 0;
963
- const goNext = () => setFocusedKey(ring[(idx + 1) % ring.length].key);
964
- const goPrev = () => setFocusedKey(ring[(idx - 1 + ring.length) % ring.length].key);
965
- if (editingRef.current) {
966
- // Editing a field: ink-text-input owns char insert / Backspace /
967
- // Delete / Left / Right and fires onSubmit on Enter. App keeps ONLY
968
- // ring traversal (Tab/Shift-Tab) + Ctrl-C (handled above). Everything
969
- // else is intentionally inert so the two input handlers don't collide.
970
- // (ink-text-input itself early-returns Tab/Shift-Tab/Up/Down/Ctrl-C,
971
- // so those reach this handler cleanly.)
972
- if (key.tab && !key.shift)
973
- goNext();
974
- else if (key.tab && key.shift)
975
- goPrev();
976
- return;
177
+ else if (this.focusedPaneIndex >= paneCount) {
178
+ this.focusedPaneIndex = 0;
977
179
  }
978
- // Ring mode — exactly Phase-2 behavior.
979
- if ((key.tab && !key.shift) || key.downArrow || key.rightArrow)
980
- goNext();
981
- else if ((key.tab && key.shift) || key.upArrow || key.leftArrow)
982
- goPrev();
983
- else if (key.return || input === " ")
984
- activate(ring[idx], key.return ? "enter" : "space");
985
- }, { isActive: interactive });
986
- const rctx = {
987
- focusedKey: effective,
988
- copiedKey,
989
- focusKey: (o) => map.get(o),
990
- interactive,
991
- fill: fillViewport,
992
- fillCols: fillViewport ? vp.cols : undefined,
993
- railFraction: props.railFraction,
994
- draft: (k) => draftFor(k),
995
- onFieldChange: (k, v) => setDraft((s) => ({ ...s, [k]: v })),
996
- onFieldSubmit,
997
- onTableFilter: tableFilter,
998
- };
999
- const content = props.interstitial != null ? (_jsx(Interstitial, { msg: props.interstitial })) : modal ? (
1000
- // Screen-ownership: render ONLY the modal, horizontally centered (the
1001
- // honest terminal "z-layer" — Ink has no z-index; base tree suppressed).
1002
- // Vertical centering deferred (no reliable terminal-height center in Ink;
1003
- // cosmetic, not load-bearing).
1004
- _jsx(Box, { flexDirection: "column", alignItems: "center", children: renderWith(modal, rctx) })) : (renderWith(vm, rctx));
1005
- // Fill the terminal so flexGrow children (the sidebar main pane) have a
1006
- // terminal-sized parent to expand into. `width` is the load-bearing dim
1007
- // (horizontal sidebar fill); `height` makes it a true full-screen surface
1008
- // (paired with the adapter's alternate-screen). `cols` is always present on
1009
- // a real TTY; if a host omits `rows`, height falls back to auto without
1010
- // breaking width. When !fillViewport (every test; non-TTY; opt-out) this
1011
- // returns `content` unchanged ⇒ byte-identical to pre-0.4.5.
1012
- if (fillViewport && typeof vp.cols === "number" && vp.cols > 0) {
1013
- return (_jsx(Box, { width: vp.cols, height: vp.rows, flexDirection: "column", children: content }));
180
+ this.lastPaneCount = paneCount;
181
+ this.root.render(_jsx(App, { vm: vm, onAction: onAction, focusedPaneIndex: this.focusedPaneIndex, sidebarFraction: this.sidebarFraction, setFieldValue: this.setFieldValue, resolveFieldValue: this.resolveFieldValue, copiedKey: this.copiedKey, copy: this.copy, navigate: this.navigateForLinks }));
1014
182
  }
1015
- return content;
1016
- }
1017
- /**
1018
- * Phase 5b terminal adapter — single-line + multi-line editors + selects.
1019
- *
1020
- * Every node renders byte-identically to Phase 1/2 when UNFOCUSED and on the
1021
- * pure `renderTree`/non-TTY path (NO_CTX → interactive:false → no editor). On
1022
- * a TTY the shell's view is interactive: a self-managed focus ring
1023
- * (Tab/Shift-Tab/arrows), Enter/Space dispatch, focus continuity; button /
1024
- * checkbox (`{checked}`) / tabs (`{value}`) / link / copy-button (OSC 52) /
1025
- * form-submit. The focused single-line `field` is an editable
1026
- * `ink-text-input`; the focused `textarea`/`code` field is the contained
1027
- * `MultilineEditor` (Enter→newline; submit via the ring's submit button;
1028
- * `code` adds a dim language label, no literal-tab). The focused `select` is
1029
- * an `ink-select-input` list; the focused `select-multiple` is the contained
1030
- * `MultiSelectInput` (Space toggles; comma-joined wire value; submit via the
1031
- * ring's submit button). Typed drafts survive poll/dispatch re-renders unless
1032
- * the field disappears or the server changes its value — mirroring
1033
- * BrowserAdapter; selects are additionally server-authoritative on ANY server
1034
- * re-render (excluded from draft preservation); password is masked;
1035
- * form-`checkbox` toggles on Space. The still-deferred tier (`file`/`modal`/
1036
- * `table`) arrives in later phases and still renders a visible fail-loud
1037
- * placeholder — never blank, never silent.
1038
- *
1039
- * LEAF MODULE: never imported by src/index.ts / src/browser.ts /
1040
- * src/server.ts, so `ink`/`react` never enter the web or server dependency
1041
- * graph (a unit test asserts this).
1042
- */
1043
- export class TuiAdapter {
1044
- instance;
1045
- disposed = false;
1046
- /** "fill" (default) = occupy the terminal + alternate-screen on a real
1047
- * interactive TTY; "content" = legacy intrinsic-content size, no
1048
- * alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
1049
- viewport;
1050
- /** `sidebar` rail width as a fraction of the terminal (fill path only).
1051
- * Default 1/3; clamped to a sane [0.15, 0.6] so a bad value can't break
1052
- * the layout. Adapter-level styling knob (NOT wire — appearance, not
1053
- * arrangement). */
1054
- sidebarFraction;
1055
- /** Set once ESC[?1049h was written, so dispose() emits the paired
1056
- * ESC[?1049l exactly once (idempotent restore — same discipline as the
1057
- * cursor restore Ink does on unmount). */
1058
- altEntered = false;
1059
- constructor(opts) {
1060
- this.viewport = opts?.viewport ?? "fill";
1061
- const f = opts?.sidebarFraction ?? 1 / 3;
1062
- this.sidebarFraction = Math.min(0.6, Math.max(0.15, f));
183
+ // B5 — bound reference to navigate(), used as the App prop. We bind once
184
+ // (in the property initializer below) so React sees a stable function
185
+ // identity across renders (avoids spurious child re-renders on click).
186
+ navigateForLinks = (url) => this.navigate(url);
187
+ /** Test-only: read the current edit value for a named field (for asserting
188
+ * draft preservation across server re-renders). */
189
+ _peekFieldValue(name) {
190
+ return this.fieldValues.get(name);
1063
191
  }
1064
- /** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
1065
- * mode, is delivered as input 0x03 and never raises SIGINT) reach the
1066
- * CLI's single idempotent shutdown. A TUI-internal seam between our two
1067
- * leaf files — NOT part of the core Adapter interface. */
1068
- requestExit = () => { };
1069
- /** session storage — in-memory, process lifetime (browser sessionStorage
1070
- * analog for a single-process CLI). Write-only per the wire contract. */
1071
- sessionStore = new Map();
1072
- /** When non-null, App renders the loud Interstitial instead of the vm. */
1073
- interstitial = null;
1074
- /** Last rendered vm/onAction — so showInterstitial can rerender through the
1075
- * SAME single Ink instance (never a 2nd inkRender). */
1076
- lastVm;
1077
- lastOnAction = () => { };
1078
- setRequestExit(fn) {
1079
- this.requestExit = fn;
192
+ /** Test-only: read the current "copied" key for asserting that activation
193
+ * set the feedback state. Returns null when no button is currently flashing. */
194
+ _peekCopiedKey() {
195
+ return this.copiedKey;
1080
196
  }
1081
- render(vm, onAction) {
1082
- this.lastVm = vm;
1083
- this.lastOnAction = onAction;
1084
- this.interstitial = null; // a fresh view supersedes any prior notice
1085
- const app = this.createApp(vm, onAction);
1086
- if (!this.instance) {
1087
- // Enter the alternate-screen buffer BEFORE the first mount so every
1088
- // frame draws there and the user's prior scrollback is untouched
1089
- // (restored verbatim by dispose()'s paired ESC[?1049l). Gated on the
1090
- // REAL process TTYs — the exact complement of the CLI's `nonInteractive`
1091
- // — so the 0.4.4 non-TTY static one-shot (pipe/CI/agent) emits NO
1092
- // escape, and unit tests (process.stdout.isTTY false under vitest)
1093
- // never enter it. "content" opts out entirely.
1094
- if (this.viewport !== "content" &&
1095
- process.stdout.isTTY === true &&
1096
- process.stdin.isTTY === true &&
1097
- !this.altEntered) {
1098
- try {
1099
- process.stdout.write("\x1b[?1049h");
1100
- this.altEntered = true;
1101
- }
1102
- catch {
1103
- /* if the enter write fails, dispose() must not emit the leave */
1104
- }
197
+ cycleFocus(forward) {
198
+ if (this.lastPaneCount === 0)
199
+ return;
200
+ this.focusedPaneIndex = forward
201
+ ? (this.focusedPaneIndex + 1) % this.lastPaneCount
202
+ : (this.focusedPaneIndex + this.lastPaneCount - 1) % this.lastPaneCount;
203
+ this.flushPending();
204
+ }
205
+ /** B5 keyboard activation of the focused pane's primary actionable.
206
+ *
207
+ * Mode mapping:
208
+ * "enter" dispatch the first button.action / link → navigate /
209
+ * copy-button copy(). No-op if the pane has inputs
210
+ * (FieldView's <input onSubmit> handles Enter for forms).
211
+ * "space" toggle the first checkbox (dispatch action with
212
+ * {checked: !node.checked}). No-op if the pane has inputs
213
+ * (Space is a printable character in text fields).
214
+ *
215
+ * Always reads pane state from the CURRENT vm (this.pending), so a key
216
+ * pressed mid-render still acts on the structure the user can see.
217
+ */
218
+ activatePane(mode) {
219
+ if (!this.pending)
220
+ return;
221
+ const summary = focusedPaneSummary(this.pending.vm, this.focusedPaneIndex);
222
+ if (summary == null || summary.hasInputs)
223
+ return;
224
+ if (mode === "enter") {
225
+ const a = summary.primaryActionable;
226
+ if (a == null)
227
+ return;
228
+ if (a.type === "button") {
229
+ this.pending.onAction(a.action);
230
+ }
231
+ else if (a.type === "link") {
232
+ this.navigate(a.href);
233
+ }
234
+ else if (a.type === "copy-button") {
235
+ this.copy(a.text);
1105
236
  }
1106
- // exitOnCtrlC:false — the CLI is the single owner of teardown; Ink must
1107
- // not race it. Ctrl-C is handled in App and routed via requestExit.
1108
- this.instance = inkRender(app, { exitOnCtrlC: false });
1109
237
  }
1110
238
  else {
1111
- // The shell re-invokes render() on every poll/dispatch; rerender keeps
1112
- // one Ink instance (and one App instance focus state survives).
1113
- this.instance.rerender(app);
239
+ const c = summary.primaryCheckbox;
240
+ if (c == null || c.action == null)
241
+ return;
242
+ this.pending.onAction({
243
+ name: c.action.name,
244
+ context: { ...(c.action.context ?? {}), checked: !c.checked },
245
+ });
1114
246
  }
1115
247
  }
1116
- /** The interactive element used by render() and by interaction unit tests. */
1117
- createApp(vm, onAction, opts) {
1118
- return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, viewport: this.viewport, railFraction: this.sidebarFraction, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
1119
- }
1120
- /** Render a full-screen loud notice through the SINGLE existing Ink instance
1121
- * (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
1122
- * diff / the ESC[?25h restore). No-op once disposed — closes the
1123
- * redirect-during-teardown rerender-after-unmount race. Process stays
1124
- * alive; the CLI owns exit (Ctrl-C via App's existing useInput → shutdown).
1125
- * Mounts the instance if a notice somehow precedes the first render (that
1126
- * is still the FIRST inkRender, not a second). */
1127
- showInterstitial(msg) {
248
+ /** Teardown restore terminal cleanly. Idempotent. */
249
+ dispose() {
1128
250
  if (this.disposed)
1129
251
  return;
1130
- this.interstitial = msg;
1131
- const vm = this.lastVm ?? { type: "text", value: "" };
1132
- const app = this.createApp(vm, this.lastOnAction);
1133
- if (this.instance)
1134
- this.instance.rerender(app);
1135
- else
1136
- this.instance = inkRender(app, { exitOnCtrlC: false });
252
+ this.disposed = true;
253
+ try {
254
+ this.root?.unmount();
255
+ }
256
+ catch { /* nothing */ }
257
+ try {
258
+ this.renderer?.destroy?.();
259
+ }
260
+ catch { /* nothing */ }
261
+ this.root = null;
262
+ this.renderer = null;
1137
263
  }
1138
- /** Standalone redirect fallback. Only reached when a consumer wires
1139
- * TuiAdapter WITHOUT ShellOptions.onRedirect core precedence means the
1140
- * vms-tui CLI's onRedirect suppresses this (proven by adapter-seam C/F).
1141
- * Origin-blind (the adapter has no shell/endpoint ref): hand off to a
1142
- * browser, else the loud interstitial. Never silent, never throws into
1143
- * core. No-op once disposed. */
264
+ // ── Capability verbs (navigate / storage / saveFile) ──────────────────────
265
+ // These are side-channel; no library-specific concerns. Implementations
266
+ // carried forward from the Ink adapter unchanged in behavior.
1144
267
  navigate(url) {
1145
268
  if (this.disposed)
1146
269
  return;
1147
- const fallback = () => this.showInterstitial(`Open this URL to continue:\n\n ${url}\n\n(no browser could be launched — open it manually)`);
1148
- if (!openExternal(url, fallback))
1149
- fallback();
270
+ if (!openExternal(url)) {
271
+ try {
272
+ process.stderr.write(`vms-tui: could not open URL automatically. Visit:\n ${url}\n`);
273
+ }
274
+ catch { /* nothing */ }
275
+ }
1150
276
  }
1151
- /** Write-only client storage. `session` → in-memory (process lifetime).
1152
- * `local` → a SYNCHRONOUS XDG state-file write — synchronous is mandatory:
1153
- * the core applies side-effects before the redirect branch (adapter-seam
1154
- * Case E), so the token must land before navigation. Fail-loud, never
1155
- * swallow, never re-throw into core (push() has no try/catch): an I/O error
1156
- * or an unparseable EXISTING store surfaces the loud interstitial + a
1157
- * stderr line and does NOT clobber a possibly-unrelated user file. */
1158
277
  storage(scope, key, value) {
1159
278
  if (scope === "session") {
1160
279
  this.sessionStore.set(key, value);
1161
280
  return;
1162
281
  }
282
+ // local → XDG state file (synchronous; the shell applies storage effects
283
+ // BEFORE the redirect branch, so the write must land before navigation).
1163
284
  const xdg = process.env.XDG_STATE_HOME;
1164
285
  const base = xdg && xdg.trim() ? xdg : join(homedir(), ".local", "state");
1165
286
  const dir = join(base, "vms-tui");
@@ -1171,10 +292,10 @@ export class TuiAdapter {
1171
292
  existing = readFileSync(file, "utf8");
1172
293
  }
1173
294
  catch {
1174
- existing = undefined; // ENOENT → fresh store (not a failure)
295
+ existing = undefined;
1175
296
  }
1176
297
  const obj = existing !== undefined
1177
- ? JSON.parse(existing) // throw → caught: no clobber
298
+ ? JSON.parse(existing)
1178
299
  : {};
1179
300
  obj[key] = value;
1180
301
  writeFileSync(file, JSON.stringify(obj));
@@ -1184,31 +305,20 @@ export class TuiAdapter {
1184
305
  try {
1185
306
  process.stderr.write(`vms-tui: storage write failed (local "${key}"): ${m}\n`);
1186
307
  }
1187
- catch {
1188
- /* stderr unavailable — the interstitial below is still loud */
1189
- }
1190
- this.showInterstitial(`Storage write FAILED — your data was NOT saved.\n\n key: ${key}\n error: ${m}`);
308
+ catch { /* nothing */ }
1191
309
  }
1192
310
  }
1193
- /** Save an authenticated-download blob to the terminal user's filesystem.
1194
- * Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
1195
- * process.cwd(). The chosen path is printed to stderr so the operator
1196
- * can find it. The filename is sanitized (no path components / no `..`)
1197
- * so a hostile/buggy server can't escape the download dir; this is the
1198
- * TUI counterpart to the browser's built-in download-name sanitization. */
1199
311
  async saveFile(data, filename, _contentType) {
312
+ // XDG_DOWNLOAD_DIR → ~/Downloads → CWD; filename sanitized against traversal.
1200
313
  const xdg = process.env.XDG_DOWNLOAD_DIR;
1201
314
  const home = homedir();
1202
315
  const dir = xdg && xdg.trim()
1203
316
  ? xdg
1204
317
  : existsSync(join(home, "Downloads")) ? join(home, "Downloads") : process.cwd();
1205
318
  mkdirSync(dir, { recursive: true });
1206
- // Strip path separators (POSIX + Windows) and any leading dots so a
1207
- // server-supplied "../etc/passwd" or "foo/bar" becomes a flat basename.
1208
- // Empty/dot-only results fall back to literal "download".
1209
319
  const sanitized = filename
1210
- .split(/[/\\]/).pop() // drop everything before the last separator
1211
- .replace(/^\.+/, "") // strip leading dots (no hidden files via traversal)
320
+ .split(/[/\\]/).pop()
321
+ .replace(/^\.+/, "")
1212
322
  .trim();
1213
323
  const safeName = sanitized.length > 0 ? sanitized : "download";
1214
324
  const out = join(dir, safeName);
@@ -1217,436 +327,824 @@ export class TuiAdapter {
1217
327
  try {
1218
328
  process.stderr.write(`vms-tui: saved ${out}\n`);
1219
329
  }
1220
- catch {
1221
- /* stderr unavailable — the file landed regardless */
1222
- }
330
+ catch { /* nothing */ }
1223
331
  }
1224
- /** Test-only: read back a session value. The wire contract has no storage
1225
- * read; this exists solely so a unit test can prove the write landed
1226
- * (same rationale as exporting osc52 for a deterministic test). */
332
+ /** Test-only: read back a session value. The wire contract has no
333
+ * storage read; this exists solely so unit tests can prove the write
334
+ * landed (parity with the Ink adapter's _peekSession). */
1227
335
  _peekSession(key) {
1228
336
  return this.sessionStore.get(key);
1229
337
  }
1230
- /** Pure ViewNode → Ink element, UNFOCUSED (NO_CTX). Used by static unit
1231
- * tests; byte-identical to Phase 1 output. */
1232
- renderTree(vm) {
1233
- return this.renderNode(vm, 0, "comfortable", undefined, NO_CTX);
338
+ }
339
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
340
+ /** Best-effort: hand a URL to the platform's browser. Returns true if a
341
+ * child process was spawned, false if no opener was available. */
342
+ function openExternal(url) {
343
+ const opener = process.platform === "darwin" ? "open" :
344
+ process.platform === "win32" ? "start" :
345
+ "xdg-open";
346
+ try {
347
+ const child = spawn(opener, [url], { detached: true, stdio: "ignore" });
348
+ child.unref();
349
+ return true;
1234
350
  }
1235
- // ── helpers ──────────────────────────────────────────────────────────────
1236
- /** Spacing rhythm. compact collapses gaps/padding (mirrors .vms--compact). */
1237
- spacing(d) {
1238
- return d === "compact" ? { gap: 0, pad: 0 } : { gap: 1, pad: 1 };
351
+ catch {
352
+ return false;
1239
353
  }
1240
- /**
1241
- * React key: the roadmap's identity heuristic — explicit id/name where
1242
- * present, else positional index. Render-only; never a wire field.
1243
- */
1244
- keyOf(node, i) {
1245
- const n = node;
1246
- return n.id ?? n.name ?? `${node.type}-${i}`;
354
+ }
355
+ // ─── Pane counting ──────────────────────────────────────────────────────────
356
+ // A "pane" is a Tab-cyclable focus region with its own scroll. The rules:
357
+ // - `section` is a pane (whether or not variant:"card");
358
+ // - top-level `list` (page.children) is a pane;
359
+ // - top-level `table` is a pane (tables benefit from independent scroll);
360
+ // - non-section nodes inside a section share that section's scroll.
361
+ // We count panes in the same depth-first order the renderer visits them,
362
+ // which makes the index assigned by the renderer's counter match the index
363
+ // `cycleFocus` is mutating. Pure walker — no rendering side effects.
364
+ function isPaneNode(node, isTopLevel) {
365
+ if (node.type === "section")
366
+ return true;
367
+ if (isTopLevel && (node.type === "list" || node.type === "table"))
368
+ return true;
369
+ return false;
370
+ }
371
+ /** B4 — find the first ModalNode anywhere in the tree (depth-first, child
372
+ * order). Returns null when there is no modal active.
373
+ *
374
+ * Used by App to (a) decide whether to render the modal overlay portal at
375
+ * app-root z-level, and (b) tell countPanes to restrict the focus cycle to
376
+ * the modal's interior. The wire allows at most one visible modal at a time
377
+ * in practice — if a future use-case wires two, the first wins; the second
378
+ * is rendered inline (returning null today) and so just isn't visible. We
379
+ * can promote this to a stack if the need arises.
380
+ */
381
+ function findModal(node) {
382
+ if (node.type === "modal")
383
+ return node;
384
+ const children = node.children;
385
+ if (children)
386
+ for (const c of children) {
387
+ const m = findModal(c);
388
+ if (m)
389
+ return m;
390
+ }
391
+ const footer = node.footer;
392
+ if (footer)
393
+ for (const c of footer) {
394
+ const m = findModal(c);
395
+ if (m)
396
+ return m;
397
+ }
398
+ return null;
399
+ }
400
+ function countPanes(vm) {
401
+ let count = 0;
402
+ const visit = (node, isTopLevel) => {
403
+ if (isPaneNode(node, isTopLevel))
404
+ count++;
405
+ // Recurse only through known container shapes — we don't introspect
406
+ // arbitrary ViewNode payloads (like table rows / form values) because
407
+ // they don't contain pane-eligible children today.
408
+ if (node.type === "page") {
409
+ for (const c of node.children)
410
+ visit(c, true);
411
+ }
412
+ else if (node.type === "section") {
413
+ // Inside a section, children DON'T become independent panes (the
414
+ // section is the pane); recurse with isTopLevel=false.
415
+ for (const c of node.children)
416
+ visit(c, false);
417
+ }
418
+ else if (node.type === "list") {
419
+ for (const c of node.children)
420
+ visit(c, false);
421
+ }
422
+ else if (node.type === "list-item") {
423
+ for (const c of node.children)
424
+ visit(c, false);
425
+ }
426
+ else if (node.type === "modal") {
427
+ for (const c of node.children)
428
+ visit(c, false);
429
+ if (node.footer)
430
+ for (const c of node.footer)
431
+ visit(c, false);
432
+ }
433
+ else if (node.type === "form") {
434
+ for (const c of node.children)
435
+ visit(c, false);
436
+ }
437
+ };
438
+ // B4 focus trap: when a modal is in the tree, only count panes within the
439
+ // modal's subtree. Outer panes still RENDER, but they're not part of the
440
+ // Tab cycle and have no focus border — the user is operationally locked
441
+ // to the modal until it's dismissed.
442
+ const modal = findModal(vm);
443
+ if (modal != null) {
444
+ for (const c of modal.children)
445
+ visit(c, false);
446
+ if (modal.footer)
447
+ for (const c of modal.footer)
448
+ visit(c, false);
449
+ return count;
1247
450
  }
1248
- /** Fail-loud placeholder — single source of the phase string. */
1249
- unsupported(label, key) {
1250
- return (_jsxs(Text, { color: "yellow", children: ["[unsupported: ", label, " \u2014 phase 5]"] }, key));
451
+ visit(vm, true);
452
+ return count;
453
+ }
454
+ function focusedPaneSummary(vm, index) {
455
+ // Step 1: find the pane node at `index`, mirroring countPanes' traversal.
456
+ let cursor = -1;
457
+ let target = null;
458
+ const visit = (node, isTopLevel) => {
459
+ // Returns true when target found (early-exit propagation).
460
+ if (isPaneNode(node, isTopLevel)) {
461
+ cursor++;
462
+ if (cursor === index) {
463
+ target = node;
464
+ return true;
465
+ }
466
+ }
467
+ if (node.type === "page") {
468
+ for (const c of node.children)
469
+ if (visit(c, true))
470
+ return true;
471
+ }
472
+ else if (node.type === "section") {
473
+ for (const c of node.children)
474
+ if (visit(c, false))
475
+ return true;
476
+ }
477
+ else if (node.type === "list") {
478
+ for (const c of node.children)
479
+ if (visit(c, false))
480
+ return true;
481
+ }
482
+ else if (node.type === "list-item") {
483
+ for (const c of node.children)
484
+ if (visit(c, false))
485
+ return true;
486
+ }
487
+ else if (node.type === "modal") {
488
+ for (const c of node.children)
489
+ if (visit(c, false))
490
+ return true;
491
+ if (node.footer)
492
+ for (const c of node.footer)
493
+ if (visit(c, false))
494
+ return true;
495
+ }
496
+ else if (node.type === "form") {
497
+ for (const c of node.children)
498
+ if (visit(c, false))
499
+ return true;
500
+ }
501
+ return false;
502
+ };
503
+ const modal = findModal(vm);
504
+ if (modal != null) {
505
+ for (const c of modal.children)
506
+ if (visit(c, false))
507
+ break;
508
+ if (target == null && modal.footer)
509
+ for (const c of modal.footer)
510
+ if (visit(c, false))
511
+ break;
1251
512
  }
1252
- /** Focused-node affordance. Unfocused → returns the element unchanged, so
1253
- * the static path stays byte-identical to Phase 1. Focused → a leading
1254
- * cyan caret (deterministic + information-honest; ▸ U+25B8 is distinct
1255
- * from the list-item markers › / ·). */
1256
- focusWrap(el, focused, key) {
1257
- if (!focused)
1258
- return el;
1259
- // Children get fixed structural keys ("caret"/"el") that are unique within
1260
- // THIS 2-element array and cannot collide with any node-derived key (the
1261
- // wrapped element is the sole child of its own Box, so its own key is
1262
- // irrelevant here). The wrapper carries `key` for its parent sibling list.
1263
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "cyan", bold: true, children: "▸ " }, "caret"), _jsx(Box, { children: el }, "el")] }, key));
513
+ else {
514
+ visit(vm, true);
1264
515
  }
1265
- /** Shipped list-item variant → marker + child tint (fail-soft: unknown ok). */
1266
- listItemStyle(variant) {
1267
- switch (variant) {
1268
- case "active":
1269
- return { marker: "›", mc: "cyan", mb: true, child: { bold: true } };
1270
- case "done":
1271
- return { marker: "✓", mc: "green", md: true, child: { dim: true } };
1272
- case "critical":
1273
- return { marker: "●", mc: "red", mb: true, child: { color: "red" } };
1274
- case "high":
1275
- return { marker: "●", mc: "red", child: { color: "red" } };
1276
- case "warning":
1277
- return { marker: "▲", mc: "yellow", child: { color: "yellow" } };
1278
- case "success":
1279
- return { marker: "●", mc: "green", child: { color: "green" } };
1280
- case "info":
1281
- return { marker: "", mc: "blue", child: { color: "blue" } };
1282
- default:
1283
- return { marker: "·", md: true };
516
+ if (target == null)
517
+ return null;
518
+ // Step 2: scan the target pane's subtree for interactives.
519
+ // TypeScript narrows let-mutated-through-a-closure conservatively (the
520
+ // `target = node` assignment is invisible to flow analysis here), so the
521
+ // post-check narrowing collapses `ViewNode | null` to `never`. The cast
522
+ // restores the type without changing runtime behavior.
523
+ const pane = target;
524
+ const heading = pane.type === "section" ? (pane.heading ?? null) : null;
525
+ let hasInputs = false;
526
+ let primaryActionable = null;
527
+ let primaryCheckbox = null;
528
+ const scan = (node) => {
529
+ if (node.type === "field")
530
+ hasInputs = true;
531
+ if (primaryActionable == null) {
532
+ if (node.type === "button")
533
+ primaryActionable = node;
534
+ else if (node.type === "link" && node.href.trim().length > 0)
535
+ primaryActionable = node;
536
+ else if (node.type === "copy-button")
537
+ primaryActionable = node;
1284
538
  }
1285
- }
1286
- /** page & section share the 4 layout presets. Information-honest, not pixel. */
1287
- layoutContainer(layout, children, density, rctx, topLevel = false) {
1288
- const sp = this.spacing(density);
1289
- const kids = (children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, undefined, rctx));
1290
- // 0.4.7 — numeric width ONLY at the page's TOP layout container (it IS
1291
- // the full terminal width). Everything below fills via Yoga align-stretch
1292
- // from this numeric anchor (proven on the real-adapter oracle). NESTED
1293
- // layoutContainer calls (a section's own children) pass topLevel=false ⇒
1294
- // no width ⇒ they get the correct PANE-relative width via stretch (a
1295
- // global numeric cols here would overflow a narrower pane). `cards` never
1296
- // gets it (uniform small-tile grid by design). Not top-level / not
1297
- // filling ⇒ {} ⇒ byte-identical.
1298
- const fillW = topLevel && rctx.fillCols ? { width: rctx.fillCols } : {};
1299
- switch (layout) {
1300
- case "split":
1301
- return (_jsx(Box, { flexDirection: "row", gap: sp.gap || 1, ...fillW, children: kids.map((el, i) => (_jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: el }, i))) }));
1302
- case "cards":
1303
- 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))) }));
1304
- case "sidebar": {
1305
- if (kids.length === 0)
1306
- return _jsx(Box, {});
1307
- const [rail, ...rest] = kids;
1308
- // Deterministic NUMERIC split when filling. `flexGrow` does NOT
1309
- // distribute reliably past a flexShrink:0 fixed-basis rail (real-
1310
- // adapter oracle: sidebar stayed 34 at cols 80 AND 160, while `split`
1311
- // — symmetric flexGrow — scaled). So when filling: fixed numeric rail
1312
- // + the exact remaining width on the main pane (it then carries the
1313
- // terminal width to its section via align-stretch). Not filling ⇒ the
1314
- // ORIGINAL flex props, byte-identical.
1315
- const cols = topLevel && rctx.fillCols ? rctx.fillCols : 0;
1316
- const filling = cols > 0;
1317
- // 0.4.9 — PROPORTIONAL rail (default 1/3, clamped [24,56]) instead of
1318
- // a hardcoded 24 (unusable on wide terminals: 24/146 ≈ 16%). Never
1319
- // below the old 24 on small terminals; capped so ultra-wide keeps the
1320
- // detail pane dominant. Tunable via `new TuiAdapter({ sidebarFraction })`.
1321
- // Only the filling branches use RAIL; the non-fill path keeps the
1322
- // literal flexBasis:24/minWidth:18 (byte-identical).
1323
- const RAIL = filling
1324
- ? Math.min(56, Math.max(24, Math.round(cols * (rctx.railFraction ?? 1 / 3))))
1325
- : 24;
1326
- const g = sp.gap || 1;
1327
- const mw = Math.max(1, cols - RAIL - g);
1328
- if (rest.length === 0) {
1329
- return filling ? (
1330
- // flexDirection:column ⇒ the lone section align-stretches to the
1331
- // numeric width (a default-row box would not width-stretch it).
1332
- _jsx(Box, { flexDirection: "column", flexShrink: 0, ...fillW, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }));
1333
- }
1334
- return (_jsxs(Box, { flexDirection: "row", gap: g, ...fillW, children: [filling ? (
1335
- // flexDirection:column ⇒ the rail's section align-stretches to
1336
- // the numeric RAIL width (a default-row box sizes it to content
1337
- // — same lesson as the single-child branch).
1338
- _jsx(Box, { flexDirection: "column", flexShrink: 0, width: RAIL, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail })), filling ? (
1339
- // Single numeric-width column directly holding the detail
1340
- // sections — exactly the `stack` shape the oracle proves scales
1341
- // (a card section align-stretches to a numeric-width parent).
1342
- // The extra auto-width wrapper that used to sit here broke that
1343
- // align-stretch chain (oracle: card detail stuck at 38).
1344
- _jsx(Box, { flexDirection: "column", gap: sp.gap, flexShrink: 1, width: mw, children: rest })) : (_jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: _jsx(Box, { flexDirection: "column", gap: sp.gap, children: rest }) }))] }));
1345
- }
1346
- default: // "stack" / undefined
1347
- return (_jsx(Box, { flexDirection: "column", gap: sp.gap, ...fillW, children: kids }));
539
+ if (primaryCheckbox == null && node.type === "checkbox" && node.action != null) {
540
+ primaryCheckbox = node;
1348
541
  }
542
+ const children = node.children;
543
+ if (children)
544
+ for (const c of children)
545
+ scan(c);
546
+ };
547
+ // Scan only the pane's children — not the pane itself (which is the
548
+ // section/list/table wrapper). For a top-level list/table pane, the
549
+ // node IS the container and its children are rows / list-items.
550
+ const paneChildren = pane.children;
551
+ if (paneChildren)
552
+ for (const c of paneChildren)
553
+ scan(c);
554
+ return { heading, hasInputs, primaryActionable, primaryCheckbox };
555
+ }
556
+ function App({ vm, onAction, focusedPaneIndex = 0, sidebarFraction = 1 / 3, setFieldValue, resolveFieldValue, copiedKey = null, copy, navigate, }) {
557
+ // B4 — detect modal at the top of every render so the focus-trap + overlay
558
+ // wiring is consistent end-to-end. When a modal exists in the tree, the
559
+ // inline ModalView returns null (so it doesn't render in-place); we render
560
+ // the modal at app-root via ModalOverlay so its z-order is above all
561
+ // outer content and its position context is the viewport, not whatever
562
+ // section the modal happened to be nested under.
563
+ const modal = findModal(vm);
564
+ const ctx = {
565
+ onAction,
566
+ focusedPaneIndex,
567
+ paneCounter: { current: 0 },
568
+ sidebarFraction,
569
+ isTopLevel: true,
570
+ inFocusedPane: false,
571
+ paneInputCounter: { current: 0 },
572
+ submitForm: null,
573
+ setFieldValue: setFieldValue ?? (() => { }),
574
+ // Default resolver: just return the wire value — no preservation outside
575
+ // the mounted adapter path (the conformance walker doesn't persist state).
576
+ resolveFieldValue: resolveFieldValue ?? ((_name, wireValue) => wireValue),
577
+ copiedKey,
578
+ copy: copy ?? (() => { }),
579
+ // B5: link click target. Default no-op for the conformance walker (which
580
+ // doesn't have an adapter to call). Real renders thread TuiAdapter.navigate.
581
+ navigate: navigate ?? (() => { }),
582
+ modalActive: modal != null,
583
+ insideModal: false,
584
+ };
585
+ // B5 — pane summary for the StatusBar's hotkey hints. Computed at render
586
+ // time from the same focusedPaneIndex used by everyone else; consistent
587
+ // with what the user sees as the focused pane.
588
+ const paneSummary = focusedPaneSummary(vm, focusedPaneIndex);
589
+ // App shell: content fills, status bar pinned at the bottom. height="100%"
590
+ // means it consumes the renderer's viewport (alt-screen size). The
591
+ // inner content box is position="relative" so the modal overlay's
592
+ // absolute positioning resolves to the content area (not the status bar).
593
+ return (_jsxs("box", { flexDirection: "column", height: "100%", children: [_jsxs("box", { flexGrow: 1, flexShrink: 1, flexDirection: "column", position: "relative", children: [renderNode(vm, ctx), modal != null ? (_jsx(ModalOverlay, { node: modal, ctx: { ...ctx, isTopLevel: true, insideModal: true, paneCounter: ctx.paneCounter } })) : null] }), _jsx(StatusBar, { summary: paneSummary })] }));
594
+ }
595
+ /** B5 — pane-aware keybind hints.
596
+ *
597
+ * Tab/Shift-Tab and Ctrl-C are universal; ↑↓ PgUp/PgDn is universal because
598
+ * scrolling happens inside OpenTUI's <scrollbox> regardless of pane type.
599
+ * The variable slot in the middle is determined by `summary`:
600
+ * - pane has fields → "Enter submit" (FieldView's <input onSubmit>)
601
+ * - pane has primary actionable → "Enter <label>" (truncated to ~16ch)
602
+ * - pane has checkbox with action → "Space toggle"
603
+ * - none of the above → no extra hint
604
+ *
605
+ * Heading (when present) shows on the right side so the user sees which
606
+ * pane they're focused in — important when multiple sections look similar.
607
+ */
608
+ function StatusBar({ summary }) {
609
+ const hint = paneActivationHint(summary);
610
+ const heading = summary?.heading ?? null;
611
+ return (_jsxs("box", { flexDirection: "row", gap: 2, paddingLeft: 1, paddingRight: 1, children: [_jsx("text", { fg: "#888888", children: "Tab" }), _jsx("text", { fg: "#aaaaaa", children: "next pane" }), _jsx("text", { fg: "#888888", children: "Shift-Tab" }), _jsx("text", { fg: "#aaaaaa", children: "prev" }), _jsx("text", { fg: "#888888", children: "\u2191\u2193 PgUp/PgDn" }), _jsx("text", { fg: "#aaaaaa", children: "scroll" }), hint != null ? (_jsxs(_Fragment, { children: [_jsx("text", { fg: "#88aaff", children: hint.key }), _jsx("text", { fg: "#aaaaaa", children: hint.label })] })) : null, _jsx("text", { fg: "#888888", children: "Ctrl-C" }), _jsx("text", { fg: "#aaaaaa", children: "quit" }), heading != null ? (_jsx("box", { flexGrow: 1, flexShrink: 1, justifyContent: "flex-end", flexDirection: "row", children: _jsx("text", { fg: "#88aaff", children: heading }) })) : null] }));
612
+ }
613
+ function paneActivationHint(summary) {
614
+ if (summary == null)
615
+ return null;
616
+ if (summary.hasInputs)
617
+ return { key: "Enter", label: "submit" };
618
+ const a = summary.primaryActionable;
619
+ if (a != null) {
620
+ let label;
621
+ if (a.type === "button")
622
+ label = a.label;
623
+ else if (a.type === "link")
624
+ label = a.label;
625
+ else
626
+ label = a.label ?? "copy"; // copy-button
627
+ // Truncate long labels so the status bar fits a single line cleanly.
628
+ const truncated = label.length > 16 ? label.slice(0, 13) + "..." : label;
629
+ return { key: "Enter", label: truncated };
1349
630
  }
1350
- // ── the node switch ──────────────────────────────────────────────────────
1351
- renderNode(node, key, density = "comfortable", inherited, rctx = NO_CTX) {
1352
- switch (node.type) {
1353
- case "page": {
1354
- const d = node.density === "compact" ? "compact" : "comfortable";
1355
- const sp = this.spacing(d);
1356
- return (_jsxs(Box, { flexDirection: "column", gap: sp.gap, ...(rctx.fillCols ? { width: rctx.fillCols, flexGrow: 1 } : {}), children: [node.title ? (_jsx(Box, { children: _jsx(Text, { bold: true, underline: true, children: node.title }) })) : null, this.layoutContainer(node.layout, node.children ?? [], d, rctx, true)] }, key));
1357
- }
1358
- case "section": {
1359
- const sp = this.spacing(density);
1360
- const card = node.variant === "card";
1361
- 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));
1362
- }
1363
- case "list":
1364
- return (_jsx(Box, { flexDirection: "column", children: (node.children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, inherited, rctx)) }, key));
1365
- case "list-item": {
1366
- const st = this.listItemStyle(node.variant);
1367
- const childInherited = st.child ?? inherited;
1368
- 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));
1369
- }
1370
- case "text": {
1371
- const s = node.style;
1372
- 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));
1373
- }
1374
- case "link": {
1375
- const fk = rctx.focusKey(node);
1376
- const focused = fk != null && fk === rctx.focusedKey;
1377
- const copied = fk != null && fk === rctx.copiedKey;
1378
- const href = (node.href ?? "").trim();
1379
- if (!href) {
1380
- return (_jsx(Box, { children: _jsx(Text, { underline: true, color: inherited?.color, dimColor: inherited?.dim, children: node.label }) }, key));
1381
- }
1382
- // OSC 8 hyperlink: ESC ]8;; <uri> BEL <label> ESC ]8;; BEL. The
1383
- // ESC introducer + BEL terminator are LOAD-BEARING — without them
1384
- // (a long-latent bug fixed in 0.4.8) this is plain `]8;;…` garbage
1385
- // text in every terminal. `\x1b`/`\x07` escapes match osc52()'s
1386
- // proven style. As the SOLE child of its own Box with no wrap, the
1387
- // string-width over-count (string-width does not strip OSC 8) stays
1388
- // contained to this line and cannot corrupt sibling layout.
1389
- const osc = `\x1b]8;;${node.href}\x07${node.label}\x1b]8;;\x07${copied ? " ✓ copied" : ""}`;
1390
- return this.focusWrap(_jsx(Box, { children: _jsx(Text, { wrap: "truncate-end", underline: true, color: inherited?.color, dimColor: inherited?.dim, children: osc }) }, key), focused, key);
1391
- }
1392
- case "stat-bar":
1393
- 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));
1394
- case "progress": {
1395
- const raw = Number(node.value);
1396
- const v = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 0;
1397
- const width = 20;
1398
- const filled = Math.round((v / 100) * width);
1399
- const bar = "█".repeat(filled) + "░".repeat(width - filled);
1400
- return (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { children: bar }), _jsxs(Text, { dimColor: true, children: [Math.round(v), "%"] })] }, key));
1401
- }
1402
- case "button": {
1403
- const fk = rctx.focusKey(node);
1404
- const focused = fk != null && fk === rctx.focusedKey;
1405
- const variant = node.variant;
1406
- const color = variant === "primary"
1407
- ? "cyan"
1408
- : variant === "danger"
1409
- ? "red"
1410
- : inherited?.color;
1411
- const bold = variant === "primary" || variant === "danger" || inherited?.bold === true;
1412
- 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);
1413
- }
1414
- case "checkbox": {
1415
- const fk = rctx.focusKey(node);
1416
- const focused = fk != null && fk === rctx.focusedKey;
1417
- 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);
1418
- }
1419
- case "tabs":
1420
- return (_jsx(Box, { flexDirection: "row", gap: 1, children: (node.tabs ?? []).map((t, i) => {
1421
- const on = t.value === node.selected;
1422
- const fk = rctx.focusKey(t);
1423
- const focused = fk != null && fk === rctx.focusedKey;
1424
- 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));
1425
- return this.focusWrap(el, focused, t.value ?? i);
1426
- }) }, key));
1427
- case "copy-button": {
1428
- const fk = rctx.focusKey(node);
1429
- const focused = fk != null && fk === rctx.focusedKey;
1430
- const copied = fk != null && fk === rctx.copiedKey;
1431
- const label = copied
1432
- ? (node.copiedLabel ?? "Copied!")
1433
- : (node.label ?? "Copy");
1434
- return this.focusWrap(_jsx(Box, { borderStyle: "round", paddingX: 1, children: _jsx(Text, { children: label }) }, key), focused, key);
1435
- }
1436
- case "form": {
1437
- const inline = node.layout === "inline";
1438
- const fk = rctx.focusKey(node);
1439
- const focused = fk != null && fk === rctx.focusedKey;
1440
- 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));
1441
- }
1442
- case "field": {
1443
- const it = node.inputType;
1444
- if (it === "hidden")
1445
- return _jsx(Box, {}, key);
1446
- const fk = rctx.focusKey(node);
1447
- const focused = fk != null && fk === rctx.focusedKey;
1448
- const dval = fk != null ? rctx.draft(fk) : undefined;
1449
- if (it === "checkbox") {
1450
- const cur = dval ?? node.value;
1451
- const truthy = !!cur && cur !== "false" && cur !== "0";
1452
- 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);
1453
- }
1454
- if (it === "textarea" || it === "code") {
1455
- // Phase 5a — multi-line editor. `code` == textarea + a dim language
1456
- // hint label (no syntax coloring: the framework only ships the
1457
- // editable monospaced surface; a terminal is already monospace).
1458
- const current = dval ?? node.value ?? "";
1459
- const labelText = (node.label ?? "") +
1460
- (it === "code" && node.language ? ` [${node.language}]` : "");
1461
- if (rctx.interactive && focused && fk != null) {
1462
- 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);
1463
- }
1464
- const hasValue = current !== "";
1465
- const display = hasValue ? current : (node.placeholder ?? "");
1466
- 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);
1467
- }
1468
- if (it === "file") {
1469
- return this.unsupported(`field(${it})`, key);
1470
- }
1471
- if (it === "select" || it === "select-multiple") {
1472
- // Phase 5b — interactive picker. `select` → ink-select-input (its
1473
- // useInput owns Up/Down/Enter/digits and does NOT consume Tab/
1474
- // Shift-Tab/Ctrl-C — verified — so App keeps ring + teardown).
1475
- // `select-multiple` → the contained MultiSelectInput (same keyboard
1476
- // contract). Editor mounts ONLY when focused on a TTY; otherwise
1477
- // (and on the pure NO_CTX/non-TTY path) a static label box,
1478
- // byte-identical when no draft exists.
1479
- const opts = node.options ?? [];
1480
- const multi = it === "select-multiple";
1481
- const current = dval ?? node.value ?? "";
1482
- const labelText = node.label ?? "";
1483
- if (rctx.interactive && focused && fk != null) {
1484
- if (multi) {
1485
- 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);
1486
- }
1487
- const items = opts.map((o) => ({ label: o.label, value: o.value }));
1488
- const idx = items.findIndex((i) => i.value === current);
1489
- 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);
1490
- }
1491
- // Static: selected label(s). ONE flat <Text> for the value line
1492
- // (Phase-1/5a nested-<Text> width pitfall).
1493
- const labelOf = (val) => opts.find((o) => o.value === val)?.label ?? val;
1494
- const display = multi
1495
- ? current
1496
- .split(",")
1497
- .map((s) => s.trim())
1498
- .filter((s) => s.length > 0)
1499
- .map(labelOf)
1500
- .join(", ")
1501
- : current
1502
- ? labelOf(current)
1503
- : "";
1504
- const hasValue = display !== "";
1505
- 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);
1506
- }
1507
- // Single-line text family: text|email|password|number|date|time|
1508
- // datetime-local (+ any unknown → text). Editable `<TextInput>` ONLY
1509
- // when focused on a TTY; otherwise (and on the pure NO_CTX/non-TTY
1510
- // path) the Phase-2 static box, byte-identical when no draft exists.
1511
- const current = dval ?? node.value ?? "";
1512
- if (rctx.interactive && focused && fk != null) {
1513
- 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);
1514
- }
1515
- const hasValue = current !== "";
1516
- const display = it === "password"
1517
- ? hasValue
1518
- ? "•".repeat(current.length)
1519
- : (node.placeholder ?? "")
1520
- : hasValue
1521
- ? current
1522
- : (node.placeholder ?? "");
1523
- 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);
631
+ if (summary.primaryCheckbox != null)
632
+ return { key: "Space", label: "toggle" };
633
+ return null;
634
+ }
635
+ function renderNode(node, ctx, key) {
636
+ switch (node.type) {
637
+ case "page": return _jsx(PageView, { node: node, ctx: ctx }, key);
638
+ case "section": return _jsx(SectionView, { node: node, ctx: ctx }, key);
639
+ case "text": return _jsx(TextView, { node: node }, key);
640
+ case "link": return _jsx(LinkView, { node: node, ctx: ctx }, key);
641
+ case "list": return _jsx(ListView, { node: node, ctx: ctx }, key);
642
+ case "list-item": return _jsx(ListItemView, { node: node, ctx: ctx }, key);
643
+ case "table": return _jsx(TableView, { node: node, ctx: ctx }, key);
644
+ case "button": return _jsx(ButtonView, { node: node, ctx: ctx }, key);
645
+ case "checkbox": return _jsx(CheckboxView, { node: node, ctx: ctx }, key);
646
+ case "tabs": return _jsx(TabsView, { node: node, ctx: ctx }, key);
647
+ case "progress": return _jsx(ProgressView, { node: node }, key);
648
+ case "stat-bar": return _jsx(StatBarView, { node: node }, key);
649
+ case "modal": return _jsx(ModalView, { node: node, ctx: ctx }, key);
650
+ case "copy-button": return _jsx(CopyButtonView, { node: node, ctx: ctx }, key);
651
+ case "form": return _jsx(FormView, { node: node, ctx: ctx }, key);
652
+ case "field": return _jsx(FieldView, { node: node, ctx: ctx }, key);
653
+ default:
654
+ return _jsx(UnsupportedView, { type: node.type }, key);
655
+ }
656
+ }
657
+ // ── page ──────────────────────────────────────────────────────────────────
658
+ // Title rendered as an unbordered header when present; children flow per
659
+ // the layout preset. `density: "compact"` tightens the gap.
660
+ function PageView({ node, ctx }) {
661
+ const gap = node.density === "compact" ? 0 : 1;
662
+ // Layout dispatch — see layoutProps() for the Yoga mapping.
663
+ const layout = layoutProps(node.layout, ctx.sidebarFraction);
664
+ const childCtx = { ...ctx, isTopLevel: true };
665
+ return (_jsxs("box", { flexDirection: "column", gap: gap, children: [node.title ? _jsx("text", { attributes: 1 /* TextAttributes.BOLD */, children: node.title }) : null, _jsx("box", { ...layout, flexShrink: 1, children: node.children.map((child, i) => renderChildWithLayout(child, childCtx, i, node.layout, ctx.sidebarFraction)) })] }));
666
+ }
667
+ // Sized container around a layout child. For "sidebar" the FIRST child gets
668
+ // sidebarFraction width; the rest split evenly. For "cards", each child is
669
+ // flex-basis ~33%. For "split" each child is flex 1.
670
+ function renderChildWithLayout(child, ctx, i, layout, sidebarFraction) {
671
+ switch (layout) {
672
+ case "split":
673
+ return (_jsx("box", { flexGrow: 1, flexShrink: 1, flexBasis: 0, flexDirection: "column", children: renderNode(child, ctx, "c") }, i));
674
+ case "cards":
675
+ return (_jsx("box", { width: `${Math.round(100 / 3)}%`, flexShrink: 1, flexDirection: "column", children: renderNode(child, ctx, "c") }, i));
676
+ case "sidebar":
677
+ if (i === 0) {
678
+ return (_jsx("box", { width: `${Math.round(sidebarFraction * 100)}%`, flexShrink: 0, flexDirection: "column", children: renderNode(child, ctx, "c") }, i));
1524
679
  }
1525
- case "modal": {
1526
- const sp = this.spacing(density);
1527
- // size → box width (cosmetic; fullscreen = full available width).
1528
- const boxWidth = node.size === "narrow"
1529
- ? 40
1530
- : node.size === "wide"
1531
- ? 90
1532
- : node.size === "fullscreen"
1533
- ? "100%"
1534
- : 60; // medium (default)
1535
- 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));
680
+ return (_jsx("box", { flexGrow: 1, flexShrink: 1, flexBasis: 0, flexDirection: "column", children: renderNode(child, ctx, "c") }, i));
681
+ case "stack":
682
+ case undefined:
683
+ default:
684
+ return renderNode(child, ctx, i);
685
+ }
686
+ }
687
+ // Map layout preset → Yoga props applied to the children container.
688
+ // Note: OpenTUI's flexWrap accepts "no-wrap" | "wrap" | "wrap-reverse"
689
+ // (kebab-case), distinct from CSS's "nowrap". Return type let TS infer to
690
+ // stay aligned with whatever OpenTUI's BoxOptions says.
691
+ function layoutProps(layout, _sidebarFraction) {
692
+ switch (layout) {
693
+ case "split":
694
+ return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
695
+ case "cards":
696
+ return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
697
+ case "sidebar":
698
+ return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
699
+ case "stack":
700
+ case undefined:
701
+ default:
702
+ return { flexDirection: "column", gap: 1 };
703
+ }
704
+ }
705
+ // ── section ───────────────────────────────────────────────────────────────
706
+ // Every section is a focus pane (Tab-cyclable, scrollable). variant:"card"
707
+ // gets a border + heading title. Plain sections get the heading rendered
708
+ // as an inline bold line above an unbordered scrollbox so overflow is still
709
+ // recoverable.
710
+ //
711
+ // Layout presets on a section apply to its children the same way page-level
712
+ // layouts do.
713
+ function SectionView({ node, ctx }) {
714
+ // B4 focus-trap gate: when a modal is active and this section is OUTSIDE
715
+ // the modal subtree, the section is not part of the Tab cycle. It still
716
+ // renders its content (so the user sees the dimmed background), but
717
+ // without a focus border and without claiming a paneCounter slot.
718
+ const isPaneFocusable = !ctx.modalActive || ctx.insideModal;
719
+ const paneIndex = isPaneFocusable ? ctx.paneCounter.current++ : -1;
720
+ const focused = isPaneFocusable && paneIndex === ctx.focusedPaneIndex;
721
+ const border = node.variant === "card";
722
+ const childCtx = {
723
+ ...ctx,
724
+ isTopLevel: false,
725
+ // Becoming a focused pane: descendant FieldViews use this to decide
726
+ // whether to grant focused=true to the first input encountered. The
727
+ // counter is per-pane so nested forms don't double up.
728
+ inFocusedPane: focused || ctx.inFocusedPane,
729
+ paneInputCounter: focused ? { current: 0 } : ctx.paneInputCounter,
730
+ };
731
+ const layout = layoutProps(node.layout, ctx.sidebarFraction);
732
+ return (_jsxs("box", { flexDirection: "column", gap: 1, flexGrow: 1, flexShrink: 1, children: [!border && node.heading ? (_jsx("text", { attributes: 1 /* BOLD */, children: node.heading })) : null, _jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, border: border, title: border ? node.heading : undefined, padding: border ? 1 : 0, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsx("box", { ...layout, children: node.children.map((child, i) => renderChildWithLayout(child, childCtx, i, node.layout, ctx.sidebarFraction)) }) })] }));
733
+ }
734
+ // ── text ──────────────────────────────────────────────────────────────────
735
+ // Maps the wire `style` field to OpenTUI text attributes + foreground color.
736
+ // pre uses no wrapping; other styles wrap naturally.
737
+ const STYLE_ATTRS = {
738
+ heading: { attributes: 1 /* BOLD */ },
739
+ subheading: { attributes: 1 /* BOLD */, fg: "#888888" },
740
+ body: {},
741
+ muted: { fg: "#888888" },
742
+ strikethrough: { attributes: 16 /* STRIKETHROUGH */, fg: "#888888" },
743
+ error: { fg: "#ff5555" },
744
+ pre: { fg: "#cccccc" },
745
+ };
746
+ function TextView({ node }) {
747
+ const style = node.style ?? "body";
748
+ const attrs = STYLE_ATTRS[style];
749
+ return (_jsx("text", { ...(attrs.attributes != null ? { attributes: attrs.attributes } : {}), ...(attrs.fg != null ? { fg: attrs.fg } : {}), children: node.value }));
750
+ }
751
+ // ── link ──────────────────────────────────────────────────────────────────
752
+ // External links emit a real OSC 8 hyperlink (\x1b]8;;url\x07label\x1b]8;;\x07);
753
+ // terminals that understand it make it clickable, terminals that don't
754
+ // degrade to the label. Empty href degrades to plain underlined text.
755
+ // Click activation (mouse + Enter) is wired in B5's interaction polish.
756
+ function LinkView({ node, ctx }) {
757
+ const ESC = String.fromCharCode(27);
758
+ const BEL = String.fromCharCode(7);
759
+ const href = node.href.trim();
760
+ const inner = href.length > 0 && node.external
761
+ ? `${ESC}]8;;${href}${BEL}${node.label}${ESC}]8;;${BEL}`
762
+ : node.label;
763
+ // B5 — mouse click hands the URL to the navigate capability (which opens
764
+ // it externally on the TUI). External links ALSO render as a real OSC-8
765
+ // hyperlink so modern terminals make Cmd/Ctrl-click work natively; the
766
+ // onMouseDown handler is the fallback for terminals without OSC-8 + the
767
+ // primary path for internal (external:false) links. Empty href → no-op.
768
+ const onMouseDown = href.length > 0
769
+ ? () => ctx.navigate(href)
770
+ : undefined;
771
+ return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(onMouseDown ? { onMouseDown } : {}), children: inner }));
772
+ }
773
+ // ── list / list-item ──────────────────────────────────────────────────────
774
+ // A top-level `list` (direct child of page) is a pane on its own; nested
775
+ // lists scroll as part of their containing section. list-item variants
776
+ // ("done", "active", …) are mapped to text colors.
777
+ const LIST_ITEM_FG = {
778
+ done: "#88cc88",
779
+ active: "#88aaff",
780
+ error: "#ff5555",
781
+ muted: "#888888",
782
+ };
783
+ function ListView({ node, ctx }) {
784
+ const isPane = ctx.isTopLevel;
785
+ if (!isPane) {
786
+ // Inline list: stays inside whatever pane wraps it; inherits inFocusedPane.
787
+ const childCtx = { ...ctx, isTopLevel: false };
788
+ return (_jsx("box", { flexDirection: "column", gap: 0, children: node.children.map((child, i) => renderNode(child, childCtx, i)) }));
789
+ }
790
+ // Top-level list → its own focus pane (subject to the B4 modal focus-trap).
791
+ const isPaneFocusable = !ctx.modalActive || ctx.insideModal;
792
+ const paneIndex = isPaneFocusable ? ctx.paneCounter.current++ : -1;
793
+ const focused = isPaneFocusable && paneIndex === ctx.focusedPaneIndex;
794
+ const childCtx = {
795
+ ...ctx,
796
+ isTopLevel: false,
797
+ inFocusedPane: focused || ctx.inFocusedPane,
798
+ paneInputCounter: focused ? { current: 0 } : ctx.paneInputCounter,
799
+ };
800
+ return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsx("box", { flexDirection: "column", gap: 0, children: node.children.map((child, i) => renderNode(child, childCtx, i)) }) }));
801
+ }
802
+ function ListItemView({ node, ctx }) {
803
+ const fg = node.variant ? LIST_ITEM_FG[node.variant] : undefined;
804
+ const childCtx = { ...ctx, isTopLevel: false };
805
+ // The variant tint applies to text children inside this item via inherited
806
+ // color (OpenTUI <text fg> wins per-element, so this only affects items
807
+ // whose children don't override). For B2 we just thread the children
808
+ // through unchanged; full per-variant styling lands in B3 (with the focus +
809
+ // selection model).
810
+ if (!fg) {
811
+ return (_jsx("box", { flexDirection: "column", gap: 0, children: node.children.map((child, i) => renderNode(child, childCtx, i)) }));
812
+ }
813
+ return (_jsx("box", { flexDirection: "column", gap: 0, children: node.children.map((child, i) => {
814
+ // If the child is a plain text node, recolor it; otherwise pass through.
815
+ if (typeof child === "object" && child !== null && child.type === "text") {
816
+ const t = child;
817
+ return (_jsx("text", { fg: fg, children: t.value }, i));
1536
818
  }
1537
- case "table": {
1538
- // Phase 5d. Serves BOTH the static (renderTree/non-TTY/unit) and
1539
- // interactive paths — interactive only adds the focus tint + mounts
1540
- // the focused filter <TextInput>. DISCIPLINE: a flex-row of
1541
- // independent fixed-width <Box> cells (the standard Ink table) is
1542
- // SAFE distinct from the Phase-5a bug (a styled <Text> nested INSIDE
1543
- // one <Text>). Every cell is ONE flat <Text>; a link cell is OSC 8 as
1544
- // the SOLE child of its own width-bounded Box (the Phase-1
1545
- // string-width over-count, contained to that cell). Focus is a
1546
- // whole-cell colour (cyan) NOT focusWrap (its external "▸ " caret
1547
- // would break fixed-column alignment); same approach as
1548
- // MultiSelectInput's highlight.
1549
- const t = node;
1550
- const cols = t.columns ?? [];
1551
- const rows = t.rows ?? [];
1552
- const canSort = !!t.sortAction;
1553
- const canFilter = !!t.filterAction;
1554
- const anyFilter = canFilter && cols.some((c) => c.filterable);
1555
- const MINW = 3, MAXW = 40, MINFILTER = 8, IND = 2;
1556
- const cellText = (c, r) => {
1557
- const v = r.cells?.[c.key] ?? "";
1558
- // Measure the LABEL, never the OSC/href (Phase-1 over-count).
1559
- return c.linkLabel && v ? c.linkLabel : v;
1560
- };
1561
- const widths = new Map();
1562
- for (const c of cols) {
1563
- let w = dispWidth(c.label) +
1564
- ((canSort && c.sortable) || t.sortColumn === c.key ? IND : 0);
1565
- for (const r of rows)
1566
- w = Math.max(w, dispWidth(cellText(c, r)));
1567
- if (canFilter && c.filterable)
1568
- w = Math.max(w, MINFILTER);
1569
- widths.set(c.key, Math.max(MINW, Math.min(MAXW, w)));
1570
- }
1571
- const w = (c) => widths.get(c.key) ?? MINW;
1572
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { flexDirection: "row", gap: 1, children: cols.map((c, ci) => {
1573
- const sortable = canSort && !!c.sortable;
1574
- const ind = t.sortColumn === c.key
1575
- ? t.sortDirection === "desc"
1576
- ? " ▼"
1577
- : " ▲"
1578
- : "";
1579
- const hk = sortable ? rctx.focusKey(c) : undefined;
1580
- const hfoc = hk != null && hk === rctx.focusedKey;
1581
- 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));
1582
- }) }), anyFilter ? (_jsx(Box, { flexDirection: "row", gap: 1, children: cols.map((c, ci) => {
1583
- if (!c.filterable)
1584
- return (_jsx(Box, { width: w(c), children: _jsx(Text, { children: " " }) }, c.key ?? ci));
1585
- const fk = rctx.focusKey(filterIdent(c));
1586
- const ffoc = fk != null && fk === rctx.focusedKey;
1587
- const cur = (fk != null ? rctx.draft(fk) : undefined) ??
1588
- c.filterValue ??
1589
- "";
1590
- 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));
1591
- }) })) : null, rows.map((r, ri) => {
1592
- const tint = this.listItemStyle(r.variant).child;
1593
- const rk = r.action ? rctx.focusKey(r) : undefined;
1594
- const rfoc = rk != null && rk === rctx.focusedKey;
1595
- return (_jsx(Box, { flexDirection: "row", gap: 1, children: cols.map((c, ci) => {
1596
- const v = r.cells?.[c.key] ?? "";
1597
- if (c.linkLabel && v) {
1598
- // Link cell. A standalone `link` node emits OSC 8, but a
1599
- // table cell is WIDTH-BOUNDED for column alignment, and
1600
- // OSC 8 here would be truncate-mangled — string-width
1601
- // over-counts the escape, so `wrap:"truncate-end"` at a
1602
- // fixed column width corrupts it (the Phase-1/5a width
1603
- // landmine is fundamentally incompatible with a fixed
1604
- // column width). So show the linkLabel as underlined
1605
- // text information-honest and aligned; standalone
1606
- // `link` nodes keep full OSC 8. (TUI-NOTES Phase 5d.)
1607
- 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));
1608
- }
1609
- 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));
1610
- }) }, r.id ?? ri));
1611
- }), rows.length === 0 ? _jsx(Text, { dimColor: true, children: "(no rows)" }) : null] }, key));
819
+ return renderNode(child, childCtx, i);
820
+ }) }));
821
+ }
822
+ // ── table ─────────────────────────────────────────────────────────────────
823
+ // Always a focus pane. Header row + body rows in a scrollbox. Sortable
824
+ // headers render a small caret indicator on the currently-sorted column.
825
+ // Filter inputs are rendered as static text rows for B2 real input wiring
826
+ // lands in B3. Clickable rows render a subtle "·" prefix; click activation
827
+ // lands in B5.
828
+ function TableView({ node, ctx }) {
829
+ const isPaneFocusable = !ctx.modalActive || ctx.insideModal;
830
+ const paneIndex = isPaneFocusable ? ctx.paneCounter.current++ : -1;
831
+ const focused = isPaneFocusable && paneIndex === ctx.focusedPaneIndex;
832
+ // B5 header click toggles sort. Direction policy: clicking the
833
+ // currently-sorted column flips asc↔desc; clicking any other column
834
+ // starts at asc. Matches BrowserAdapter table semantics.
835
+ const onHeaderClick = (columnKey) => {
836
+ if (!node.sortAction)
837
+ return;
838
+ const direction = node.sortColumn === columnKey && node.sortDirection === "asc" ? "desc" : "asc";
839
+ ctx.onAction({
840
+ name: node.sortAction.name,
841
+ context: { ...(node.sortAction.context ?? {}), column: columnKey, direction },
842
+ });
843
+ };
844
+ return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [_jsx("box", { flexDirection: "row", gap: 2, children: node.columns.map((c) => {
845
+ const isSorted = node.sortColumn === c.key;
846
+ const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
847
+ // B5 only sortable headers respond to clicks (matches BrowserAdapter).
848
+ const clickable = c.sortable && node.sortAction != null;
849
+ const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
850
+ return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
851
+ }) }), node.columns.some((c) => c.filterable) ? (_jsx("box", { flexDirection: "row", gap: 2, children: node.columns.map((c) => (_jsx("text", { fg: "#888888", children: c.filterable ? (c.filterValue ? `[${c.filterValue}]` : "[filter]") : "" }, c.key))) })) : null, node.rows.map((row, ri) => {
852
+ // B5 — row click dispatches row.action when present.
853
+ const onRowClick = row.action
854
+ ? () => ctx.onAction(row.action)
855
+ : undefined;
856
+ return (_jsx("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: node.columns.map((c) => {
857
+ const cell = row.cells[c.key] ?? "";
858
+ if (c.linkLabel != null && cell.length > 0) {
859
+ // Cell is a link — emit OSC-8 for external links, plain
860
+ // underlined text otherwise. External links are clickable
861
+ // natively via the terminal's OSC-8 support; internal cell
862
+ // links route through navigate (B5).
863
+ const ESC = String.fromCharCode(27);
864
+ const BEL = String.fromCharCode(7);
865
+ const inner = c.linkExternal
866
+ ? `${ESC}]8;;${cell}${BEL}${c.linkLabel}${ESC}]8;;${BEL}`
867
+ : c.linkLabel;
868
+ const cellClick = !c.linkExternal && cell.length > 0
869
+ ? () => ctx.navigate(cell)
870
+ : undefined;
871
+ return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(cellClick ? { onMouseDown: cellClick } : {}), children: inner }, c.key));
872
+ }
873
+ return _jsx("text", { children: cell }, c.key);
874
+ }) }, row.id ?? ri));
875
+ })] }) }));
876
+ }
877
+ // ── minimum-viable text surface for the rest of the node set ───────────────
878
+ // Same as B1 — text only, no interactivity. Full widgets land in B3/B4.
879
+ function ButtonView({ node, ctx }) {
880
+ const fg = node.variant === "danger" ? "#ff5555"
881
+ : node.variant === "primary" ? "#88aaff"
882
+ : undefined;
883
+ // B5 — mouse click dispatches the button's action. Keyboard activation
884
+ // (Enter on the focused pane's primary actionable) is wired at the
885
+ // renderer key handler, not here.
886
+ const onMouseDown = () => ctx.onAction(node.action);
887
+ return (_jsxs("text", { ...(fg != null ? { fg } : {}), onMouseDown: onMouseDown, children: ["[ ", node.label, " ]"] }));
888
+ }
889
+ function CheckboxView({ node, ctx }) {
890
+ const glyph = node.checked ? "[x]" : "[ ]";
891
+ // B5 mouse click toggles. When node.action is defined, dispatch it with
892
+ // the NEW checked value merged into context (matches BrowserAdapter's
893
+ // checkbox onChange wire: `{checked: !node.checked}`). When no action,
894
+ // the click has no semantic effect — the visual state is server-owned
895
+ // and only changes on the next server response that flips node.checked.
896
+ const onMouseDown = () => {
897
+ if (!node.action)
898
+ return;
899
+ ctx.onAction({
900
+ name: node.action.name,
901
+ context: { ...(node.action.context ?? {}), checked: !node.checked },
902
+ });
903
+ };
904
+ return _jsxs("text", { onMouseDown: onMouseDown, children: [glyph, " ", node.label ?? ""] });
905
+ }
906
+ // ── tabs ──────────────────────────────────────────────────────────────────
907
+ // Click a tab → dispatch `node.action` with `{ ...node.action.context,
908
+ // value: tab.value }` merged. Selected tab renders bold; unselected tabs
909
+ // render dim. Keyboard activation (Tab on focused tab-bar → cycle) is B5.
910
+ function TabsView({ node, ctx }) {
911
+ return (_jsx("box", { flexDirection: "row", gap: 1, children: node.tabs.map((t) => {
912
+ const selected = t.value === node.selected;
913
+ const onMouseDown = () => {
914
+ ctx.onAction({
915
+ name: node.action.name,
916
+ context: { ...(node.action.context ?? {}), value: t.value },
917
+ });
918
+ };
919
+ return (_jsx("text", { onMouseDown: onMouseDown, attributes: selected ? 1 /* BOLD */ : 0, ...(selected ? {} : { fg: "#888888" }), children: t.label }, t.value));
920
+ }) }));
921
+ }
922
+ // ── progress ──────────────────────────────────────────────────────────────
923
+ // Visual bar: [████░░░░░░] N% using FULL BLOCK (U+2588) / LIGHT SHADE
924
+ // (U+2591). 20-cell bar width is a fixed convention — wide enough to be
925
+ // readable, narrow enough to fit inside list rows / cards. The trailing
926
+ // "N%" string is what keeps conformance happy (token "%" surfaces).
927
+ const PROGRESS_BAR_WIDTH = 20;
928
+ function ProgressView({ node }) {
929
+ const pct = Math.max(0, Math.min(100, Math.round(node.value)));
930
+ const filled = Math.round((pct / 100) * PROGRESS_BAR_WIDTH);
931
+ const bar = "█".repeat(filled) + "░".repeat(PROGRESS_BAR_WIDTH - filled);
932
+ return _jsxs("text", { children: [bar, " ", pct, "%"] });
933
+ }
934
+ // ── stat-bar ──────────────────────────────────────────────────────────────
935
+ // Row of label+value pairs separated by │ (U+2502) for visual grouping.
936
+ // Labels render dim; values render normal — same scan pattern used in
937
+ // most status-bar UIs.
938
+ function StatBarView({ node }) {
939
+ return (_jsx("box", { flexDirection: "row", gap: 1, children: node.stats.map((s, i) => (_jsxs("box", { flexDirection: "row", gap: 1, children: [i > 0 ? _jsx("text", { fg: "#555555", children: "\u2502" }) : null, _jsx("text", { fg: "#888888", children: s.label }), _jsx("text", { children: String(s.value) })] }, i))) }));
940
+ }
941
+ // ── modal ─────────────────────────────────────────────────────────────────
942
+ // Modals are PORTALED to app-root by App (see findModal + ModalOverlay).
943
+ // The inline ModalView (invoked when renderNode hits a "modal" in the tree)
944
+ // returns null so the modal doesn't render twice — once in-tree, once at
945
+ // app-root. App's ModalOverlay handles the actual rendering with absolute
946
+ // positioning + focus-trap context.
947
+ function ModalView(_props) {
948
+ return null;
949
+ }
950
+ const MODAL_SIZE = {
951
+ narrow: { width: 36, height: 14 },
952
+ medium: { width: 60, height: 18 },
953
+ wide: { width: 88, height: 24 },
954
+ fullscreen: { width: "90%", height: "90%" },
955
+ };
956
+ function ModalOverlay({ node, ctx }) {
957
+ const size = MODAL_SIZE[node.size ?? "medium"];
958
+ const childCtx = {
959
+ ...ctx,
960
+ isTopLevel: false,
961
+ insideModal: true,
962
+ paneInputCounter: { current: 0 },
963
+ };
964
+ return (_jsx("box", { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", backgroundColor: "#000000", zIndex: 100, children: _jsxs("box", { flexDirection: "column", border: true, padding: 1, title: node.title, backgroundColor: "#1a1a1a", borderColor: "#88aaff", width: size.width, ...(size.height != null ? { height: size.height } : {}), children: [_jsx("box", { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: node.children.map((child, i) => renderNode(child, childCtx, i)) }), (node.footer && node.footer.length > 0) || node.dismissAction ? (_jsxs("box", { flexDirection: "row", gap: 2, justifyContent: "flex-end", children: [node.footer ? node.footer.map((child, i) => renderNode(child, childCtx, `f${i}`)) : null, node.dismissAction ? (_jsx("text", { onMouseDown: () => ctx.onAction(node.dismissAction), children: "[ Close ]" })) : null] })) : null] }) }));
965
+ }
966
+ // ── copy-button ───────────────────────────────────────────────────────────
967
+ // Click → OSC-52 write to the clipboard + ephemeral "Copied!" label.
968
+ // Adapter holds the `copiedKey` state; the view checks ctx.copiedKey
969
+ // against node.text to decide which label to render. The static
970
+ // conformance walker sees only the label text (which token sequence
971
+ // remains stable: just `node.label ?? "Copy"`).
972
+ function CopyButtonView({ node, ctx }) {
973
+ const isCopied = ctx.copiedKey === node.text;
974
+ const label = isCopied
975
+ ? (node.copiedLabel ?? "Copied!")
976
+ : (node.label ?? "Copy");
977
+ return (_jsxs("text", { onMouseDown: () => ctx.copy(node.text), children: ["[ ", label, " ]"] }));
978
+ }
979
+ // ── form ──────────────────────────────────────────────────────────────────
980
+ // Renders children + the submit button (decorative for B3 — Enter on any
981
+ // child input triggers form submit via FieldView's onSubmit handler).
982
+ // On submit: walks this form's children recursively to collect field names,
983
+ // reads each from the adapter's fieldValues map (falling back to wire), and
984
+ // dispatches submitAction with `{ [name]: value, ... }` merged into context.
985
+ function FormView({ node, ctx }) {
986
+ // Snapshot the form for the closure — same-instance fine since the closure
987
+ // is recreated on every render (which is every server response).
988
+ const submitThisForm = () => {
989
+ const merged = {
990
+ ...(node.submitAction.context ?? {}),
991
+ };
992
+ const collect = (n) => {
993
+ if (n.type === "field") {
994
+ const wireValue = n.value ?? "";
995
+ // The map may not have an entry for fields the user hasn't touched
996
+ // (or for hidden fields we register on render). Fall back to the
997
+ // wire value in that case — the resolveFieldValue plumbing keeps the
998
+ // two in sync for fields that DID render.
999
+ const v = ctx.resolveFieldValue(n.name, wireValue);
1000
+ // Checkbox-typed fields submit as boolean, not string.
1001
+ merged[n.name] = n.inputType === "checkbox" ? v === "true" : v;
1612
1002
  }
1613
- default:
1614
- // field(file) is handled in the field case; any unknown node type
1615
- // (or a still-deferred one) → fail-loud, never blank.
1616
- return this.unsupported(node.type, key);
1617
- }
1618
- }
1619
- /** Resolves when the Ink app unmounts; immediate if nothing was rendered. */
1620
- async waitUntilExit() {
1621
- if (!this.instance)
1003
+ const children = n.children;
1004
+ if (children)
1005
+ for (const c of children)
1006
+ collect(c);
1007
+ };
1008
+ for (const child of node.children)
1009
+ collect(child);
1010
+ ctx.onAction({ name: node.submitAction.name, context: merged });
1011
+ };
1012
+ const childCtx = {
1013
+ ...ctx,
1014
+ isTopLevel: false,
1015
+ submitForm: submitThisForm,
1016
+ };
1017
+ // Layout preset on form: "stack" (default — fields stacked) or "inline"
1018
+ // (field row + submit on one line, the add-bar/search-bar pattern).
1019
+ const isInline = node.layout === "inline";
1020
+ return (_jsxs("box", { flexDirection: isInline ? "row" : "column", gap: 1, children: [node.children.map((child, i) => renderNode(child, childCtx, i)), _jsxs("text", { attributes: 1 /* BOLD */, children: ["[ ", node.submitLabel ?? "Submit", " ]"] })] }));
1021
+ }
1022
+ // ── field ─────────────────────────────────────────────────────────────────
1023
+ // Real OpenTUI input/textarea/select wired to the adapter's field state.
1024
+ // Layout per inputType:
1025
+ // hidden → null (still REGISTERS the wire value so form
1026
+ // submit picks it up).
1027
+ // text/email/password/ → <text label> + <input value=wire ...>.
1028
+ // number/date/time/ Single-line; onInput tracks edits;
1029
+ // datetime-local onSubmit→form submit or field action.
1030
+ // textarea / code → <text label> + <textarea initialValue=wire>.
1031
+ // Multiline; B3 doesn't ship syntax highlighting
1032
+ // (code-as-textarea is a deliberate B3 simplification —
1033
+ // OpenTUI <code> is read-only render, not editor).
1034
+ // select / select-multiple → <text label> + <select options=[...] >.
1035
+ // B3 ships single-select; select-multiple semantics
1036
+ // are deferred (no native OpenTUI multi-select
1037
+ // widget; would need a custom focusable list — B5).
1038
+ // checkbox → <text "[x] label" or "[ ] label"> — decorative
1039
+ // for B3 (toggle interactivity in B5).
1040
+ // file → <text "{label}: [file: …]"> placeholder —
1041
+ // upload via OpenTUI input is impractical without
1042
+ // native file-picker UX; targeted at B4 misc.
1043
+ //
1044
+ // Draft preservation:
1045
+ // resolveFieldValue() is the source of truth. It checks "did the server
1046
+ // change the wire value since last render" — if so, snap the user's edit
1047
+ // back to the new server value. Otherwise, the user's edit survives. This
1048
+ // matches the BrowserAdapter contract in AGENTS.md.
1049
+ //
1050
+ // Conformance:
1051
+ // The label is always rendered as a sibling <text>, so the static walker
1052
+ // sees it. The wire value is surfaced via <input value=…> / <textarea
1053
+ // initialValue=…> — the conformance walker is extended in this phase to
1054
+ // read those props as user-visible information.
1055
+ function FieldView({ node, ctx }) {
1056
+ const wireValue = node.value ?? "";
1057
+ // Resolve through the adapter so draft preservation runs as a side effect
1058
+ // even for hidden fields (the form needs their wire value at submit time).
1059
+ const currentValue = ctx.resolveFieldValue(node.name, wireValue);
1060
+ if (node.inputType === "hidden")
1061
+ return null;
1062
+ const label = node.label ?? node.name;
1063
+ // First focusable input inside the focused pane wins auto-focus. Sub-pane
1064
+ // traversal (Tab between multiple inputs) is B5 — for B3 the user gets
1065
+ // exactly one focused field per pane, which is enough to type into a form.
1066
+ const inputIndex = ctx.paneInputCounter.current++;
1067
+ const focused = ctx.inFocusedPane && inputIndex === 0;
1068
+ // Submit handler — common to text/textarea/select. Wired to the parent
1069
+ // form (if any), else dispatches the field's own action (immediate-dispatch).
1070
+ const handleSubmit = (latestValue) => {
1071
+ if (latestValue !== undefined)
1072
+ ctx.setFieldValue(node.name, latestValue);
1073
+ if (ctx.submitForm) {
1074
+ ctx.submitForm();
1622
1075
  return;
1623
- try {
1624
- await this.instance.waitUntilExit();
1625
1076
  }
1626
- catch {
1627
- // Ink rejects waitUntilExit on some exit paths; treat as exited.
1077
+ if (node.action) {
1078
+ ctx.onAction({
1079
+ name: node.action.name,
1080
+ context: {
1081
+ ...(node.action.context ?? {}),
1082
+ [node.name]: latestValue ?? ctx.resolveFieldValue(node.name, wireValue),
1083
+ },
1084
+ });
1628
1085
  }
1086
+ };
1087
+ // ── textarea / code: multi-line editor ─────────────────────────────────
1088
+ if (node.inputType === "textarea" || node.inputType === "code") {
1089
+ return (_jsxs("box", { flexDirection: "column", gap: 0, children: [_jsx("text", { fg: "#888888", children: label }), _jsx("textarea", { initialValue: currentValue, placeholder: node.placeholder ?? null, focused: focused, onSubmit: () => handleSubmit() }, `${node.name}::wire::${wireValue}`)] }));
1629
1090
  }
1630
- /** Idempotent terminal restore unmount restores cursor/raw mode. */
1631
- dispose() {
1632
- if (this.disposed)
1633
- return;
1634
- this.disposed = true;
1635
- this.instance?.unmount(); // Ink restores cursor/raw mode on the alt screen
1636
- if (this.altEntered) {
1637
- // THEN leave the alternate-screen buffer the user's prior terminal
1638
- // (scrollback, cursor) is restored verbatim. Reached on EVERY exit
1639
- // path: the CLI funnels shutdown / SIGINT / SIGTERM / uncaught /
1640
- // unhandledRejection / process 'exit' all through dispose(), so a
1641
- // crash or kill cannot strand the user on the alt screen. Idempotent
1642
- // (disposed guard + altEntered cleared).
1643
- this.altEntered = false;
1644
- try {
1645
- process.stdout.write("\x1b[?1049l");
1646
- }
1647
- catch {
1648
- /* stdout gone during teardown nothing more we can do */
1649
- }
1650
- }
1091
+ // ── select / select-multiple: dropdown picker ──────────────────────────
1092
+ if (node.inputType === "select" || node.inputType === "select-multiple") {
1093
+ const options = (node.options ?? []).map((o) => ({
1094
+ name: o.label,
1095
+ description: "",
1096
+ value: o.value,
1097
+ }));
1098
+ let selectedIndex = options.findIndex((o) => String(o.value) === currentValue);
1099
+ if (selectedIndex < 0)
1100
+ selectedIndex = 0;
1101
+ return (_jsxs("box", { flexDirection: "column", gap: 0, children: [_jsx("text", { fg: "#888888", children: label }), _jsx("select", { options: options, selectedIndex: selectedIndex, focused: focused, onChange: (_idx, opt) => {
1102
+ if (opt)
1103
+ ctx.setFieldValue(node.name, String(opt.value ?? opt.name));
1104
+ }, onSelect: (_idx, opt) => {
1105
+ const next = opt ? String(opt.value ?? opt.name) : currentValue;
1106
+ handleSubmit(next);
1107
+ } }, `${node.name}::wire::${wireValue}`)] }));
1108
+ }
1109
+ // ── checkbox: decorative glyph for B3 (toggle wiring is B5) ───────────
1110
+ if (node.inputType === "checkbox") {
1111
+ // Wire contract: checkbox-typed field's value is "true" | "false".
1112
+ const checked = currentValue === "true";
1113
+ const glyph = checked ? "[x]" : "[ ]";
1114
+ return (_jsxs("text", { fg: focused ? "#88aaff" : undefined, children: [glyph, " ", label] }));
1115
+ }
1116
+ // ── file: B4 placeholder (no native file picker in terminal) ───────────
1117
+ if (node.inputType === "file") {
1118
+ return (_jsxs("box", { flexDirection: "column", gap: 0, children: [_jsx("text", { fg: "#888888", children: label }), _jsx("text", { fg: "#888888", children: "[file upload \u2014 coming in B4 misc]" })] }));
1651
1119
  }
1120
+ // ── Default: single-line input (text/email/password/number/date/time/datetime-local) ──
1121
+ // OpenTUI's <input> doesn't model these subtypes; they all map to a
1122
+ // single-line text editor. Password masking would require either a custom
1123
+ // renderer or post-processing — deferred to B5 polish (the framework wire
1124
+ // exposes inputType so the server knows it's a password; the visual
1125
+ // affordance can come later). For date/time/datetime-local: the wire is
1126
+ // already a string, and a freeform text field is the lowest-common-denominator
1127
+ // terminal UX (a graphical date picker doesn't fit the medium anyway).
1128
+ return (_jsxs("box", { flexDirection: "column", gap: 0, children: [_jsx("text", { fg: "#888888", children: label }), _jsx("input", { value: currentValue, placeholder: node.placeholder ?? "", focused: focused, onInput: (v) => ctx.setFieldValue(node.name, v),
1129
+ // OpenTUI types onSubmit as an intersection of (value:string)=>void
1130
+ // and (event:SubmitEvent)=>void, so the param at use-site is the
1131
+ // union — narrow it before dispatch. The wire layer's SubmitEvent
1132
+ // is an empty interface (no payload), so the string form is the only
1133
+ // one carrying the user's edit; ignore the event form.
1134
+ onSubmit: (v) => {
1135
+ handleSubmit(typeof v === "string" ? v : undefined);
1136
+ } }, `${node.name}::wire::${wireValue}`)] }));
1137
+ }
1138
+ function UnsupportedView({ type }) {
1139
+ return _jsxs("text", { fg: "#ff5555", children: ["[unknown node type: ", type, "]"] });
1140
+ }
1141
+ // ─── Conformance support ─────────────────────────────────────────────────────
1142
+ // renderTree returns a React element for the static / non-mounting path
1143
+ // used by the cross-adapter conformance suite. It does NOT mount a renderer;
1144
+ // the test layer (a manual function-component walker; see
1145
+ // test/conformance.tui.test.ts) invokes the components directly. Note that
1146
+ // the App component is hooks-free — focus state is passed via props with a
1147
+ // safe default — so the walker works without a React reconciler.
1148
+ export function renderTree(vm) {
1149
+ return _jsx(App, { vm: vm, onAction: () => { } });
1652
1150
  }