@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/README.md +10 -6
- package/dist/browser.js +4 -1
- package/dist/index.d.ts +2 -0
- package/dist/server.js +6 -1
- package/dist/tui-cli.js +85 -98
- package/dist/tui.d.ts +64 -147
- package/dist/tui.js +1046 -1548
- package/package.json +9 -11
- package/styles/default.css +17 -3
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 (
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
if (
|
|
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
|
-
|
|
958
|
-
|
|
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
|
-
|
|
979
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
/**
|
|
1065
|
-
*
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1082
|
-
this.
|
|
1083
|
-
|
|
1084
|
-
this.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
/**
|
|
1117
|
-
|
|
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.
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
this.
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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;
|
|
295
|
+
existing = undefined;
|
|
1175
296
|
}
|
|
1176
297
|
const obj = existing !== undefined
|
|
1177
|
-
? JSON.parse(existing)
|
|
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()
|
|
1211
|
-
.replace(/^\.+/, "")
|
|
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
|
|
1225
|
-
* read; this exists solely so
|
|
1226
|
-
* (
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
spacing(d) {
|
|
1238
|
-
return d === "compact" ? { gap: 0, pad: 0 } : { gap: 1, pad: 1 };
|
|
351
|
+
catch {
|
|
352
|
+
return false;
|
|
1239
353
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
const
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
}
|