@loomwork/ui-renderer-terminal 0.0.0-stage → 0.1.1

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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +369 -2
  4. package/dist/esm/index.js +6 -0
  5. package/dist/esm/index.js.map +1 -0
  6. package/dist/esm/inert.js +13 -0
  7. package/dist/esm/inert.js.map +1 -0
  8. package/dist/esm/interactive.js +677 -0
  9. package/dist/esm/interactive.js.map +1 -0
  10. package/dist/esm/registry.js +428 -0
  11. package/dist/esm/registry.js.map +1 -0
  12. package/dist/esm/status.js +64 -0
  13. package/dist/esm/status.js.map +1 -0
  14. package/dist/esm/watch.js +17 -0
  15. package/dist/esm/watch.js.map +1 -0
  16. package/dist/index.d.ts +9 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +22 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inert.d.ts +12 -0
  21. package/dist/inert.d.ts.map +1 -0
  22. package/dist/inert.js +17 -0
  23. package/dist/inert.js.map +1 -0
  24. package/dist/interactive.d.ts +142 -0
  25. package/dist/interactive.d.ts.map +1 -0
  26. package/dist/interactive.js +685 -0
  27. package/dist/interactive.js.map +1 -0
  28. package/dist/registry.d.ts +55 -0
  29. package/dist/registry.d.ts.map +1 -0
  30. package/dist/registry.js +434 -0
  31. package/dist/registry.js.map +1 -0
  32. package/dist/status.d.ts +49 -0
  33. package/dist/status.d.ts.map +1 -0
  34. package/dist/status.js +69 -0
  35. package/dist/status.js.map +1 -0
  36. package/dist/watch.d.ts +9 -0
  37. package/dist/watch.d.ts.map +1 -0
  38. package/dist/watch.js +20 -0
  39. package/dist/watch.js.map +1 -0
  40. package/package.json +60 -3
  41. package/src/index.ts +25 -0
  42. package/src/inert.ts +14 -0
  43. package/src/interactive.ts +868 -0
  44. package/src/registry.ts +630 -0
  45. package/src/status.ts +103 -0
  46. package/src/watch.ts +22 -0
@@ -0,0 +1,677 @@
1
+ import { HYDRATION_VALUES_PREFIX, createSay, oneLineChatText, optionsProblem, viewedVersionOf, } from "@loomwork/ui-core";
2
+ import { MAX_MULTI_SELECTED, SELECT_ACTION, SELECT_MANY_ACTION, SUBMIT_TEXT_ACTION, multiSelectValueIssues, newSubmitKey, stripUnsafeChars, validateSelectValue, validateTextValue, } from "@loomwork/ui-core/chat-ui";
3
+ import { inert } from "./inert";
4
+ import { renderExperience } from "./status";
5
+ /** Bracketed paste: the terminal wraps a paste in these, so it can never be taken for typing. */
6
+ const PASTE_START = "\x1b[200~";
7
+ const PASTE_END = "\x1b[201~";
8
+ /** Enable / disable bracketed paste mode. */
9
+ const PASTE_ON = "\x1b[?2004h";
10
+ const PASTE_OFF = "\x1b[?2004l";
11
+ /** Most paste text one span keeps (a paste is a draft, not a file). */
12
+ const MAX_PASTE = 1 << 20;
13
+ const pasteText = (raw) => stripUnsafeChars(raw.replace(/\r\n?|\u2028|\u2029/g, "\n").replace(/\t/g, " "));
14
+ /** One printable code point: what a `char` key may carry. Anything else is not typed text. */
15
+ export function isPrintableChar(ch) {
16
+ if (typeof ch !== "string" || ch === "" || Array.from(ch).length !== 1)
17
+ return false;
18
+ const cp = ch.codePointAt(0) ?? 0;
19
+ if (cp < 0x20 || cp === 0x2028 || cp === 0x2029)
20
+ return false;
21
+ return stripUnsafeChars(ch) === ch;
22
+ }
23
+ const NAMED = [
24
+ ["\x1b[Z", "shift-tab"],
25
+ ["\x1b[A", "up"],
26
+ ["\x1b[B", "down"],
27
+ ["\x1bOA", "up"],
28
+ ["\x1bOB", "down"],
29
+ ["\t", "tab"],
30
+ ["\r\n", "enter"],
31
+ ["\r", "enter"],
32
+ ["\n", "enter"],
33
+ ["\x7f", "backspace"],
34
+ ["\b", "backspace"],
35
+ ["\x03", "interrupt"],
36
+ ];
37
+ /** How long a bracketed paste may sit with no byte before it is abandoned (ms of the injected clock). */
38
+ const PASTE_IDLE_MS = 2000;
39
+ /** The longest proper suffix of `s` that is a prefix of the paste end marker (a marker split across chunks). */
40
+ function markerTail(s) {
41
+ for (let n = Math.min(PASTE_END.length - 1, s.length); n > 0; n--) {
42
+ if (PASTE_END.startsWith(s.slice(s.length - n)))
43
+ return s.slice(s.length - n);
44
+ }
45
+ return "";
46
+ }
47
+ /**
48
+ * A decoder that remembers an unfinished bracketed paste across chunks. `decodeKeys` is the
49
+ * stateless form (one chunk, a fresh decoder). It returns EVERY input unit, dropped sequences and
50
+ * non-printables included as `ignored`, so a caller can tell a lone key from one with company.
51
+ *
52
+ * Paste mode always ends: at the end marker (even split across chunks), at Ctrl-C (the paste is
53
+ * discarded and the interrupt delivered), when the paste outgrows MAX_PASTE (it is discarded, and
54
+ * the rest is swallowed up to the end marker, never typed), or after `pasteIdleMs` of silence, when the
55
+ * remainder (if any) is drained: discarded up to the end marker, Ctrl-C, or MAX_PASTE more UTF-16
56
+ * code units AND `pasteIdleMs` of silence (a still-flowing paste keeps draining). A drain or an
57
+ * overflowed paste that ends inside a chunk emits an `ignored` key, so what follows is never sole.
58
+ * Nothing it discards ever becomes a key.
59
+ */
60
+ export function createKeyDecoder(opts = {}) {
61
+ const now = opts.now ?? Date.now;
62
+ const idleMs = opts.pasteIdleMs ?? PASTE_IDLE_MS;
63
+ let pasting;
64
+ let overflow = false;
65
+ let carry = "";
66
+ let lastByteAt = 0;
67
+ // After an idle-abandon the rest of the stalled paste may still arrive: drain (discard) it up to
68
+ // the end marker or Ctrl-C, so it is never typed. Silence does NOT end the drain (a slow link can
69
+ // stall again mid-paste, and what follows is still the paste); a bound (MAX_PASTE UTF-16 code
70
+ // units) PLUS idleMs of silence does, so a lost end marker costs at most that, never all input.
71
+ let draining = false;
72
+ let drained = 0;
73
+ const leavePaste = () => {
74
+ pasting = undefined;
75
+ overflow = false;
76
+ carry = "";
77
+ };
78
+ return (chunk) => {
79
+ const keys = [];
80
+ let i = 0;
81
+ if (pasting !== undefined && now() - lastByteAt > idleMs) {
82
+ leavePaste();
83
+ draining = true;
84
+ drained = 0;
85
+ }
86
+ if (draining && drained > MAX_PASTE && now() - lastByteAt > idleMs) {
87
+ // Past the bound AND silent for idleMs: the end marker is lost, hand the input back.
88
+ draining = false;
89
+ carry = "";
90
+ }
91
+ if (draining) {
92
+ lastByteAt = now();
93
+ const text = carry + chunk;
94
+ const lead = carry.length;
95
+ carry = "";
96
+ const end = text.indexOf(PASTE_END);
97
+ const ctrl = text.indexOf("\x03");
98
+ if (ctrl !== -1 && (end === -1 || ctrl < end)) {
99
+ draining = false;
100
+ keys.push({ name: "interrupt" });
101
+ i = ctrl - lead + 1;
102
+ }
103
+ else if (end !== -1) {
104
+ draining = false;
105
+ // Discarded content yields no key of its own, so mark the end: whatever follows in this
106
+ // chunk is then never a sole key.
107
+ keys.push({ name: "ignored" });
108
+ i = end - lead + PASTE_END.length;
109
+ }
110
+ else {
111
+ // Past the bound the drain ends only after idleMs of silence (checked above), so a
112
+ // still-flowing paste is never typed.
113
+ drained += chunk.length;
114
+ carry = markerTail(text);
115
+ return keys;
116
+ }
117
+ }
118
+ while (i < chunk.length) {
119
+ if (pasting !== undefined) {
120
+ lastByteAt = now();
121
+ const text = carry + chunk.slice(i);
122
+ const lead = carry.length;
123
+ carry = "";
124
+ const end = text.indexOf(PASTE_END);
125
+ const ctrl = text.indexOf("\x03");
126
+ if (ctrl !== -1 && (end === -1 || ctrl < end)) {
127
+ // Ctrl-C always gets out: the paste is dropped and the interrupt is delivered.
128
+ leavePaste();
129
+ keys.push({ name: "interrupt" });
130
+ i += ctrl - lead + 1;
131
+ continue;
132
+ }
133
+ const stop = end === -1 ? text.length - markerTail(text).length : end;
134
+ if (!overflow) {
135
+ pasting += text.slice(0, stop);
136
+ if (pasting.length > MAX_PASTE) {
137
+ overflow = true;
138
+ pasting = "";
139
+ }
140
+ }
141
+ if (end === -1) {
142
+ carry = text.slice(stop);
143
+ return keys;
144
+ }
145
+ keys.push(overflow ? { name: "ignored" } : { name: "paste", text: pasteText(pasting) });
146
+ leavePaste();
147
+ i += end - lead + PASTE_END.length;
148
+ continue;
149
+ }
150
+ const rest = chunk.slice(i);
151
+ if (rest.startsWith(PASTE_START)) {
152
+ pasting = "";
153
+ lastByteAt = now();
154
+ i += PASTE_START.length;
155
+ continue;
156
+ }
157
+ const hit = NAMED.find(([seq]) => rest.startsWith(seq));
158
+ if (hit !== undefined) {
159
+ keys.push({ name: hit[1] });
160
+ i += hit[0].length;
161
+ continue;
162
+ }
163
+ if (rest.startsWith("\x1b")) {
164
+ // A control string (OSC/DCS/SOS/PM/APC) is dropped WHOLE, payload included; so is a CSI/SS3.
165
+ const seq = CONTROL_STRING.exec(rest) ?? CSI.exec(rest) ?? SS3.exec(rest);
166
+ if (seq !== null) {
167
+ keys.push({ name: "ignored" });
168
+ i += seq[0].length;
169
+ continue;
170
+ }
171
+ keys.push({ name: "escape" });
172
+ i += 1;
173
+ continue;
174
+ }
175
+ const cp = rest.codePointAt(0) ?? 0;
176
+ const ch = String.fromCodePoint(cp);
177
+ i += ch.length;
178
+ // Controls, bidi and invisible marks are never typed text (core's one unsafe set).
179
+ keys.push(isPrintableChar(ch) ? { name: "char", char: ch } : { name: "ignored" });
180
+ }
181
+ return keys;
182
+ };
183
+ }
184
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC begins the sequences being matched
185
+ const CONTROL_STRING = /^\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)?/;
186
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC begins the sequences being matched
187
+ const CSI = /^\x1b\[[0-9;?]*[ -/]*[@-~]/;
188
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC begins the sequences being matched
189
+ const SS3 = /^\x1bO[@-~]/;
190
+ /**
191
+ * Split a chunk of terminal input into keys. Unknown escape sequences (arrows left/right, function
192
+ * keys, mouse reports, OSC strings) are dropped whole rather than leaking their bytes in as
193
+ * characters; a lone ESC is `escape`. Printable code points become `char` keys; a bracketed paste
194
+ * (`ESC[200~ … ESC[201~`) is ONE `paste` key, text only.
195
+ */
196
+ export function decodeKeys(chunk) {
197
+ return createKeyDecoder()(chunk).filter((k) => k.name !== "ignored");
198
+ }
199
+ /** The lines that differ between two frames, in order. Equal frames diff to nothing. */
200
+ export function diffFrames(prev, next) {
201
+ const out = [];
202
+ const n = Math.max(prev.length, next.length);
203
+ for (let i = 0; i < n; i++)
204
+ if (prev[i] !== next[i])
205
+ out.push({ line: i, text: next[i] });
206
+ return out;
207
+ }
208
+ const CLEAR_SCREEN = "\x1b[2J\x1b[H";
209
+ const at = (line) => `\x1b[${line + 1};1H`;
210
+ /** The escape sequences that apply `patches`, then park the cursor under the frame. */
211
+ export function paintPatches(patches, nextLength) {
212
+ if (patches.length === 0)
213
+ return "";
214
+ const body = patches.map((p) => `${at(p.line)}\x1b[2K${p.text ?? ""}`).join("");
215
+ return `${body}${at(nextLength)}`;
216
+ }
217
+ /** Every widget under `layout`, in document order (a grid child is a `GridCell` holding its node). */
218
+ function widgetsOf(layout) {
219
+ const out = [];
220
+ const visit = (node) => {
221
+ if ("_tag" in node) {
222
+ out.push(node);
223
+ return;
224
+ }
225
+ for (const child of node.children)
226
+ visit("node" in child ? child.node : child);
227
+ };
228
+ visit(layout);
229
+ return out;
230
+ }
231
+ const propLabel = (raw) => typeof raw === "string" ? oneLineChatText(raw, 80) : "";
232
+ /**
233
+ * Run the renderer as a live, keyboard-driven session.
234
+ *
235
+ * Consent (`y` / `n`, a submit) acts only as the SOLE unit of an input chunk: every decoded unit
236
+ * counts, dropped escape sequences and non-printables included. KNOWN LIMITS, because a chunk
237
+ * boundary is all the terminal gives us: (1) a terminal WITHOUT bracketed paste delivers a one-char
238
+ * paste (`y`) as a one-key chunk, indistinguishable from a keypress; (2) a host that reads byte by
239
+ * byte (slow serial / ssh) makes every key a one-key chunk, so a multi-line non-bracketed paste can
240
+ * reach `\t`, `y`. For such hosts turn on `confirmConsent`, which asks for the same key again in a
241
+ * later chunk. Default off.
242
+ */
243
+ export function interactiveTerminal(initial, opts) {
244
+ const { store, onAction, input, output, onPaint, labels, compile } = opts;
245
+ const say = createSay(opts.i18n);
246
+ const focusMark = labels?.focus ?? "▸";
247
+ const hint = labels?.hint ?? "⇥ ⏎ ⎋";
248
+ const renderOpts = (({ store: _s, onAction: _a, input: _i, output: _o, onPaint: _p, onInterrupt: _t, labels: _l, compile: _c, confirmConsent: _cc, now: _n, pasteIdleMs: _pi, ...rest }) => rest)(opts);
249
+ let blueprint = initial;
250
+ let focusId;
251
+ let mode = { kind: "nav" };
252
+ let note = "";
253
+ let previous;
254
+ let stopped = false;
255
+ let current = "";
256
+ let queue = Promise.resolve();
257
+ /** The APPROVE/REJECT already sent for a pending approval: further y/n are ignored until the frame moves. */
258
+ let armed;
259
+ let answered;
260
+ const boundValue = (w) => {
261
+ const p = w.binding?.path;
262
+ if (p === undefined || !p.startsWith(`${HYDRATION_VALUES_PREFIX}.`))
263
+ return undefined;
264
+ return store.value(p.slice(HYDRATION_VALUES_PREFIX.length + 1));
265
+ };
266
+ const statusOf = (w) => {
267
+ if (w.binding === undefined)
268
+ return undefined;
269
+ const st = store.status(w.binding.path.slice(HYDRATION_VALUES_PREFIX.length + 1));
270
+ return st === undefined
271
+ ? undefined
272
+ : { state: st.state, ...(st.seq !== undefined && { seq: st.seq }) };
273
+ };
274
+ function capture() {
275
+ const statuses = new Map();
276
+ const parts = [blueprint.id];
277
+ for (const c of controlsNow()) {
278
+ const st = statusOf(c.widget);
279
+ statuses.set(c.id, st);
280
+ parts.push([c.id, c.kind, st?.state, st?.seq]);
281
+ }
282
+ return { id: blueprint.id, statuses, fp: JSON.stringify(parts) };
283
+ }
284
+ function controlsNow() {
285
+ const out = [];
286
+ for (const w of widgetsOf(blueprint.layout)) {
287
+ const label = propLabel(w.props.label) || propLabel(w.title);
288
+ if (w.kind === "ApprovalCard") {
289
+ // Like the web: no pending approval, no controls.
290
+ if (boundValue(w))
291
+ out.push({
292
+ id: w.id,
293
+ kind: "approval",
294
+ label: label || say("lu.approval.approve"),
295
+ widget: w,
296
+ });
297
+ }
298
+ else if (w.kind === "SelectInput") {
299
+ if (optionsProblem(w.props.options) === undefined)
300
+ out.push({ id: w.id, kind: "select", label: label || say("lu.input.choose"), widget: w });
301
+ }
302
+ else if (w.kind === "MultiSelectInput") {
303
+ if (multiSelectValueIssues(boundValue(w)).length === 0)
304
+ out.push({ id: w.id, kind: "multi", label: label || say("lu.input.choose"), widget: w });
305
+ }
306
+ else if (w.kind === "TextInput") {
307
+ out.push({ id: w.id, kind: "text", label: label || say("lu.input.message"), widget: w });
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+ const optionsOf = (w) => optionsProblem(w.props.options) === undefined
313
+ ? w.props.options
314
+ : [];
315
+ const multiOf = (w) => {
316
+ const v = boundValue(w);
317
+ return multiSelectValueIssues(v).length === 0 ? v : undefined;
318
+ };
319
+ const maxOf = (w) => {
320
+ const m = w.props.maxSelected;
321
+ return typeof m === "number" && Number.isInteger(m) && m >= 1 && m <= MAX_MULTI_SELECTED
322
+ ? m
323
+ : MAX_MULTI_SELECTED;
324
+ };
325
+ /** The checklist's cursor and ticks, held to the CURRENT options and cap: what is drawn is what a send uses. */
326
+ const reconcile = (w, index, picks) => {
327
+ const m = multiOf(w);
328
+ const have = new Set((m?.options ?? []).map((o) => o.value));
329
+ const kept = [...new Set(picks)].filter((p) => have.has(p)).slice(0, maxOf(w));
330
+ const last = Math.max(0, (m?.options.length ?? 1) - 1);
331
+ return { index: Math.min(Math.max(0, index), last), picks: kept };
332
+ };
333
+ function build() {
334
+ const body = renderExperience(blueprint, store, renderOpts);
335
+ const controls = controlsNow();
336
+ if (controls.length === 0)
337
+ return inert(body);
338
+ if (focusId === undefined || !controls.some((c) => c.id === focusId)) {
339
+ focusId = controls[0]?.id;
340
+ mode = { kind: "nav" };
341
+ }
342
+ const lines = ["─".repeat(24)];
343
+ for (const c of controls) {
344
+ const focused = c.id === focusId;
345
+ const mark = focused ? focusMark : " ";
346
+ if (c.kind === "approval") {
347
+ lines.push(`${mark} ${c.label} [y] ${say("lu.approval.approve")} [n] ${say("lu.approval.reject")}`);
348
+ }
349
+ else if (c.kind === "text") {
350
+ const editing = focused && mode.kind === "text";
351
+ lines.push(`${mark} ${c.label}: ${editing ? `${mode.draft}▏` : "…"}`);
352
+ }
353
+ else if (c.kind === "multi") {
354
+ lines.push(`${mark} ${c.label}`);
355
+ const m = multiOf(c.widget);
356
+ if (focused && mode.kind === "multi" && m !== undefined) {
357
+ const rec = reconcile(c.widget, mode.index, mode.picks);
358
+ const at = rec.index;
359
+ const from = Math.max(0, Math.min(at - 4, m.options.length - 10));
360
+ const picks = new Set(rec.picks);
361
+ m.options.slice(from, from + 10).forEach((o, k) => {
362
+ const i = from + k;
363
+ lines.push(` ${i === at ? "›" : " "} [${picks.has(o.value) ? "x" : " "}] ${oneLineChatText(o.label, 60)}`);
364
+ });
365
+ }
366
+ }
367
+ else {
368
+ lines.push(`${mark} ${c.label}`);
369
+ const w = controls.find((x) => x.id === c.id)?.widget;
370
+ if (focused && mode.kind === "pick" && w !== undefined) {
371
+ const at = mode.index;
372
+ optionsOf(w).forEach((o, i) => lines.push(` ${i === at ? "›" : " "} ${i + 1}) ${oneLineChatText(o.label, 60)}`));
373
+ }
374
+ }
375
+ }
376
+ if (note !== "")
377
+ lines.push(note);
378
+ lines.push(hint);
379
+ // The WHOLE frame is made inert, footer included: the i18n words, the hint and focus marks, and
380
+ // the draft echo are as host- or viewer-authored as any title.
381
+ return inert(`${body}${lines.join("\n")}\n`);
382
+ }
383
+ function paint(force = false) {
384
+ if (stopped)
385
+ return;
386
+ current = build();
387
+ const next = current.replace(/\n$/, "").split("\n");
388
+ const prev = previous;
389
+ previous = next;
390
+ if (output === undefined)
391
+ return;
392
+ if (prev === undefined || force) {
393
+ output.write(`${CLEAR_SCREEN}${next.join("\n")}\n`);
394
+ onPaint?.({ mode: "full", changedLines: next.length, totalLines: next.length });
395
+ return;
396
+ }
397
+ const patches = diffFrames(prev, next);
398
+ if (patches.length > 0)
399
+ output.write(paintPatches(patches, next.length));
400
+ onPaint?.({
401
+ mode: patches.length === 0 ? "none" : "patch",
402
+ changedLines: patches.length,
403
+ totalLines: next.length,
404
+ });
405
+ }
406
+ async function dispatch(action, w, snap) {
407
+ if (onAction === undefined) {
408
+ note = say("lu.chat.dispatch.unavailable");
409
+ return false;
410
+ }
411
+ // Stamped from what the viewer saw when the key ARRIVED, not from whatever is current by the
412
+ // time a queued key runs.
413
+ const status = snap.statuses.get(w.id);
414
+ const stamped = {
415
+ ...action,
416
+ viewedVersion: viewedVersionOf(snap.id, status, compile),
417
+ };
418
+ note = say("lu.chat.sending");
419
+ paint();
420
+ try {
421
+ const out = (await onAction(stamped));
422
+ if (typeof out === "object" && out !== null && out.ok === false) {
423
+ note = say(out.reason === "stale" ? "lu.chat.dispatch.stale" : "lu.chat.dispatch.failed");
424
+ return false;
425
+ }
426
+ note = say("lu.input.sent");
427
+ return true;
428
+ }
429
+ catch {
430
+ note = say("lu.chat.dispatch.failed");
431
+ return false;
432
+ }
433
+ }
434
+ /**
435
+ * A consent key (y/n on an approval, any submit) acts only when it is the SOLE key of its input
436
+ * chunk and the frame it was pressed on is still the current one. Otherwise it is refused.
437
+ */
438
+ function consent(sole, snap) {
439
+ if (!sole)
440
+ return false;
441
+ if (snap.fp !== capture().fp) {
442
+ note = say("lu.chat.dispatch.stale");
443
+ return false;
444
+ }
445
+ return true;
446
+ }
447
+ async function handle(key, sole, snap) {
448
+ // Any key but the confirming one disarms; the confirming key is checked below.
449
+ const wasArmed = armed;
450
+ armed = undefined;
451
+ if (key.name === "interrupt") {
452
+ (opts.onInterrupt ?? stop)();
453
+ return "stop";
454
+ }
455
+ if (key.name === "char" && !isPrintableChar(key.char))
456
+ return undefined;
457
+ const controls = controlsNow();
458
+ const i = controls.findIndex((c) => c.id === focusId);
459
+ const c = controls[i];
460
+ if (c === undefined)
461
+ return undefined;
462
+ if (mode.kind === "text") {
463
+ if (key.name === "escape")
464
+ mode = { kind: "nav" };
465
+ else if (key.name === "backspace")
466
+ mode = { kind: "text", draft: Array.from(mode.draft).slice(0, -1).join("") };
467
+ else if (key.name === "char")
468
+ mode = { kind: "text", draft: mode.draft + (key.char ?? "") };
469
+ else if (key.name === "paste")
470
+ mode = { kind: "text", draft: mode.draft + (key.text ?? "") };
471
+ else if (key.name === "enter") {
472
+ if (!consent(sole, snap))
473
+ return "stop";
474
+ const v = validateTextValue(mode.draft);
475
+ if (!v.ok) {
476
+ note = v.error === "too-long" ? say("lu.input.error.tooLong") : say("lu.chat.typeFirst");
477
+ }
478
+ else {
479
+ const ok = await dispatch({
480
+ type: SUBMIT_TEXT_ACTION,
481
+ widgetId: c.id,
482
+ payload: { text: v.text, key: newSubmitKey() },
483
+ }, c.widget, snap);
484
+ // Like the web: what was typed is kept until the host says it arrived.
485
+ if (ok)
486
+ mode = { kind: "nav" };
487
+ return "stop";
488
+ }
489
+ }
490
+ return undefined;
491
+ }
492
+ if (mode.kind === "pick") {
493
+ const options = optionsOf(c.widget);
494
+ let choose;
495
+ if (key.name === "escape")
496
+ mode = { kind: "nav" };
497
+ else if (key.name === "up")
498
+ mode = { kind: "pick", index: Math.max(0, mode.index - 1) };
499
+ else if (key.name === "down")
500
+ mode = { kind: "pick", index: Math.min(options.length - 1, mode.index + 1) };
501
+ else if (key.name === "enter")
502
+ choose = mode.index;
503
+ else if (key.name === "char" && key.char !== undefined && /^[1-9]$/.test(key.char))
504
+ choose = Number(key.char) - 1;
505
+ if (choose !== undefined) {
506
+ const picked = options[choose];
507
+ const v = picked === undefined ? undefined : validateSelectValue(options, picked.value);
508
+ if (v?.ok === true) {
509
+ if (!consent(sole, snap))
510
+ return "stop";
511
+ const ok = await dispatch({
512
+ type: SELECT_ACTION,
513
+ widgetId: c.id,
514
+ payload: { value: v.value, key: newSubmitKey() },
515
+ }, c.widget, snap);
516
+ if (ok)
517
+ mode = { kind: "nav" };
518
+ return "stop";
519
+ }
520
+ }
521
+ return undefined;
522
+ }
523
+ if (mode.kind === "multi") {
524
+ const m = multiOf(c.widget);
525
+ if (m === undefined) {
526
+ mode = { kind: "nav" };
527
+ return undefined;
528
+ }
529
+ mode = { kind: "multi", ...reconcile(c.widget, mode.index, mode.picks) };
530
+ const last = m.options.length - 1;
531
+ if (key.name === "escape")
532
+ mode = { kind: "nav" };
533
+ else if (key.name === "up")
534
+ mode = { ...mode, index: Math.max(0, mode.index - 1) };
535
+ else if (key.name === "down")
536
+ mode = { ...mode, index: Math.min(last, mode.index + 1) };
537
+ else if (key.name === "char" && key.char === " ") {
538
+ const o = m.options[mode.index];
539
+ if (o !== undefined) {
540
+ const has = mode.picks.includes(o.value);
541
+ if (has)
542
+ mode = { ...mode, picks: mode.picks.filter((p) => p !== o.value) };
543
+ else if (mode.picks.length < maxOf(c.widget))
544
+ mode = { ...mode, picks: [...mode.picks, o.value] };
545
+ }
546
+ }
547
+ else if (key.name === "enter") {
548
+ if (!consent(sole, snap))
549
+ return "stop";
550
+ const ok = await dispatch({
551
+ type: SELECT_MANY_ACTION,
552
+ widgetId: c.id,
553
+ payload: { values: [...mode.picks], key: newSubmitKey() },
554
+ }, c.widget, snap);
555
+ if (ok)
556
+ mode = { kind: "nav" };
557
+ return "stop";
558
+ }
559
+ return undefined;
560
+ }
561
+ // Navigation. A paste is text: with no draft open it is nothing.
562
+ if (key.name === "paste")
563
+ return undefined;
564
+ note = "";
565
+ const move = (d) => {
566
+ focusId = controls[(i + d + controls.length) % controls.length]?.id;
567
+ };
568
+ if (key.name === "tab" || key.name === "down")
569
+ move(1);
570
+ else if (key.name === "shift-tab" || key.name === "up")
571
+ move(-1);
572
+ else if (c.kind === "approval" &&
573
+ key.name === "char" &&
574
+ (key.char === "y" || key.char === "n")) {
575
+ if (!consent(sole, snap))
576
+ return "stop";
577
+ // One answer per pending approval: until the frame moves (the host answered, or a new
578
+ // version landed) a second y/n is ignored, so a repeat cannot double-submit.
579
+ if (answered !== undefined && answered.id === c.id && answered.fp === snap.fp)
580
+ return "stop";
581
+ if (opts.confirmConsent === true &&
582
+ !(wasArmed?.id === c.id && wasArmed.fp === snap.fp && wasArmed.char === key.char)) {
583
+ const char = key.char ?? "";
584
+ armed = { id: c.id, fp: snap.fp, char };
585
+ note = labels?.confirm ?? `${char} ×2`;
586
+ return "stop";
587
+ }
588
+ const approved = key.char === "y";
589
+ const ok = await dispatch({ type: approved ? "APPROVE" : "REJECT", widgetId: c.id, payload: { approved } }, c.widget, snap);
590
+ answered = ok ? { id: c.id, fp: snap.fp } : undefined;
591
+ return "stop";
592
+ }
593
+ else if (key.name === "enter") {
594
+ if (c.kind === "text")
595
+ mode = { kind: "text", draft: "" };
596
+ else if (c.kind === "select")
597
+ mode = { kind: "pick", index: 0 };
598
+ else if (c.kind === "multi")
599
+ mode = { kind: "multi", ...reconcile(c.widget, 0, multiOf(c.widget)?.selected ?? []) };
600
+ }
601
+ return undefined;
602
+ }
603
+ const run = (work) => {
604
+ queue = queue.then(work, work);
605
+ return queue;
606
+ };
607
+ /** One INPUT CHUNK: the keys the terminal delivered together (a paste arrives this way). */
608
+ async function chunkOf(keys) {
609
+ // The frame the keys were pressed on is captured NOW, before they queue behind anything.
610
+ const snap = capture();
611
+ const sole = keys.length === 1;
612
+ await run(async () => {
613
+ for (const k of keys) {
614
+ if (stopped)
615
+ return;
616
+ const before = mode.kind;
617
+ const out = await handle(k, sole, snap);
618
+ paint();
619
+ // A key that changed mode or submitted ends the chunk: what follows in the SAME chunk was
620
+ // typed (or pasted) for the old mode, never for the new one.
621
+ if (out === "stop" || mode.kind !== before)
622
+ return;
623
+ }
624
+ });
625
+ }
626
+ /** Programmatic keys: each is its own arrival, in order (a caller pressing keys means each one). */
627
+ async function press(...keys) {
628
+ await Promise.all(keys.map((k) => chunkOf([k])));
629
+ }
630
+ const decode = createKeyDecoder({
631
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
632
+ ...(opts.pasteIdleMs !== undefined ? { pasteIdleMs: opts.pasteIdleMs } : {}),
633
+ });
634
+ const onData = (chunk) => {
635
+ void chunkOf(decode(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")));
636
+ };
637
+ // Bracketed paste is on while a session reads input AND paints, so a paste arrives wrapped.
638
+ const pasteMode = input !== undefined && output !== undefined;
639
+ const stops = [];
640
+ function stop() {
641
+ if (stopped)
642
+ return;
643
+ stopped = true;
644
+ for (const s of stops.splice(0))
645
+ s();
646
+ if (pasteMode)
647
+ output?.write(PASTE_OFF);
648
+ }
649
+ stops.push(store.runtimeSubscribe(() => paint()));
650
+ if (opts.projectionSubscribe !== undefined)
651
+ stops.push(opts.projectionSubscribe(() => paint()));
652
+ if (input !== undefined) {
653
+ input.setRawMode?.(true);
654
+ input.resume?.();
655
+ input.on("data", onData);
656
+ stops.push(() => {
657
+ (input.off ?? input.removeListener)?.call(input, "data", onData);
658
+ input.setRawMode?.(false);
659
+ });
660
+ }
661
+ if (pasteMode)
662
+ output?.write(PASTE_ON);
663
+ paint();
664
+ return {
665
+ frame: () => current,
666
+ controls: () => controlsNow().map(({ id, kind, label }) => ({ id, kind, label })),
667
+ focused: () => focusId,
668
+ send: (chunk) => chunkOf(decode(chunk)),
669
+ press,
670
+ update(next) {
671
+ blueprint = next;
672
+ paint();
673
+ },
674
+ stop,
675
+ };
676
+ }
677
+ //# sourceMappingURL=interactive.js.map