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