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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,868 @@
1
+ import type { Blueprint, Layout, Widget } from "@loomwork/blueprint-runtime";
2
+ import {
3
+ HYDRATION_VALUES_PREFIX,
4
+ type LuI18n,
5
+ type StampedAction,
6
+ createSay,
7
+ oneLineChatText,
8
+ optionsProblem,
9
+ viewedVersionOf,
10
+ } from "@loomwork/ui-core";
11
+ import type { ActionOutcome } from "@loomwork/ui-core/browser";
12
+ import {
13
+ MAX_MULTI_SELECTED,
14
+ type MultiSelectValue,
15
+ SELECT_ACTION,
16
+ SELECT_MANY_ACTION,
17
+ SUBMIT_TEXT_ACTION,
18
+ multiSelectValueIssues,
19
+ newSubmitKey,
20
+ stripUnsafeChars,
21
+ validateSelectValue,
22
+ validateTextValue,
23
+ } from "@loomwork/ui-core/chat-ui";
24
+ import type { HydrationStore } from "@loomwork/ui-hydration/client";
25
+ import { inert } from "./inert";
26
+ import { type RenderExperienceOptions, renderExperience } from "./status";
27
+
28
+ // ── Interactive terminal ───────────────────────────────────────────────────────────────────────
29
+ // `renderExperience` is a snapshot: a string in, a string out, byte-for-byte stable. This module is
30
+ // the OTHER way to use the same renderer, and is strictly opt-in: it keeps the snapshot as the body
31
+ // of every frame and adds (a) a controls footer a viewer can move through and act on, and (b) an
32
+ // incremental paint that rewrites only the lines that changed. Input and output are injected
33
+ // (`TerminalInput` / `TerminalOutput`), so it runs the same under a TTY, a pipe or a test.
34
+
35
+ /** What the session reads keys from: a TTY's `process.stdin`, or any emitter of `data` chunks. */
36
+ export interface TerminalInput {
37
+ on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
38
+ off?(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
39
+ removeListener?(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
40
+ /** A TTY's raw mode: set while the session is live so keys arrive unbuffered. Optional. */
41
+ setRawMode?(raw: boolean): unknown;
42
+ resume?(): unknown;
43
+ }
44
+
45
+ /** Where frames are painted: a TTY's `process.stdout`, or any sink with `write`. */
46
+ export interface TerminalOutput {
47
+ write(chunk: string): unknown;
48
+ }
49
+
50
+ export type KeyName =
51
+ | "tab"
52
+ | "shift-tab"
53
+ | "up"
54
+ | "down"
55
+ | "enter"
56
+ | "escape"
57
+ | "backspace"
58
+ | "interrupt"
59
+ | "paste"
60
+ | "ignored"
61
+ | "char";
62
+ export interface Key {
63
+ readonly name: KeyName;
64
+ /** The printable character, for `name: "char"`. */
65
+ readonly char?: string;
66
+ /** The pasted text, for `name: "paste"`: already inert, newlines normalised to `\n`. It is TEXT only, never a command. */
67
+ readonly text?: string;
68
+ }
69
+
70
+ /** Bracketed paste: the terminal wraps a paste in these, so it can never be taken for typing. */
71
+ const PASTE_START = "\x1b[200~";
72
+ const PASTE_END = "\x1b[201~";
73
+ /** Enable / disable bracketed paste mode. */
74
+ const PASTE_ON = "\x1b[?2004h";
75
+ const PASTE_OFF = "\x1b[?2004l";
76
+ /** Most paste text one span keeps (a paste is a draft, not a file). */
77
+ const MAX_PASTE = 1 << 20;
78
+
79
+ const pasteText = (raw: string): string =>
80
+ stripUnsafeChars(raw.replace(/\r\n?|\u2028|\u2029/g, "\n").replace(/\t/g, " "));
81
+
82
+ /** One printable code point: what a `char` key may carry. Anything else is not typed text. */
83
+ export function isPrintableChar(ch: unknown): ch is string {
84
+ if (typeof ch !== "string" || ch === "" || Array.from(ch).length !== 1) return false;
85
+ const cp = ch.codePointAt(0) ?? 0;
86
+ if (cp < 0x20 || cp === 0x2028 || cp === 0x2029) return false;
87
+ return stripUnsafeChars(ch) === ch;
88
+ }
89
+
90
+ const NAMED: ReadonlyArray<readonly [string, KeyName]> = [
91
+ ["\x1b[Z", "shift-tab"],
92
+ ["\x1b[A", "up"],
93
+ ["\x1b[B", "down"],
94
+ ["\x1bOA", "up"],
95
+ ["\x1bOB", "down"],
96
+ ["\t", "tab"],
97
+ ["\r\n", "enter"],
98
+ ["\r", "enter"],
99
+ ["\n", "enter"],
100
+ ["\x7f", "backspace"],
101
+ ["\b", "backspace"],
102
+ ["\x03", "interrupt"],
103
+ ];
104
+
105
+ /** How long a bracketed paste may sit with no byte before it is abandoned (ms of the injected clock). */
106
+ const PASTE_IDLE_MS = 2000;
107
+
108
+ /** The longest proper suffix of `s` that is a prefix of the paste end marker (a marker split across chunks). */
109
+ function markerTail(s: string): string {
110
+ for (let n = Math.min(PASTE_END.length - 1, s.length); n > 0; n--) {
111
+ if (PASTE_END.startsWith(s.slice(s.length - n))) return s.slice(s.length - n);
112
+ }
113
+ return "";
114
+ }
115
+
116
+ export interface KeyDecoderOptions {
117
+ /** The clock the paste idle bound reads (ms). Default `Date.now`. Injected so a test needs no sleep. */
118
+ readonly now?: () => number;
119
+ /** A paste with no byte for this long is discarded and paste mode left. Default 2000. */
120
+ readonly pasteIdleMs?: number;
121
+ }
122
+
123
+ /**
124
+ * A decoder that remembers an unfinished bracketed paste across chunks. `decodeKeys` is the
125
+ * stateless form (one chunk, a fresh decoder). It returns EVERY input unit, dropped sequences and
126
+ * non-printables included as `ignored`, so a caller can tell a lone key from one with company.
127
+ *
128
+ * Paste mode always ends: at the end marker (even split across chunks), at Ctrl-C (the paste is
129
+ * discarded and the interrupt delivered), when the paste outgrows MAX_PASTE (it is discarded, and
130
+ * the rest is swallowed up to the end marker, never typed), or after `pasteIdleMs` of silence, when the
131
+ * remainder (if any) is drained: discarded up to the end marker, Ctrl-C, or MAX_PASTE more UTF-16
132
+ * code units AND `pasteIdleMs` of silence (a still-flowing paste keeps draining). A drain or an
133
+ * overflowed paste that ends inside a chunk emits an `ignored` key, so what follows is never sole.
134
+ * Nothing it discards ever becomes a key.
135
+ */
136
+ export function createKeyDecoder(opts: KeyDecoderOptions = {}): (chunk: string) => Key[] {
137
+ const now = opts.now ?? Date.now;
138
+ const idleMs = opts.pasteIdleMs ?? PASTE_IDLE_MS;
139
+ let pasting: string | undefined;
140
+ let overflow = false;
141
+ let carry = "";
142
+ let lastByteAt = 0;
143
+ // After an idle-abandon the rest of the stalled paste may still arrive: drain (discard) it up to
144
+ // the end marker or Ctrl-C, so it is never typed. Silence does NOT end the drain (a slow link can
145
+ // stall again mid-paste, and what follows is still the paste); a bound (MAX_PASTE UTF-16 code
146
+ // units) PLUS idleMs of silence does, so a lost end marker costs at most that, never all input.
147
+ let draining = false;
148
+ let drained = 0;
149
+ const leavePaste = (): void => {
150
+ pasting = undefined;
151
+ overflow = false;
152
+ carry = "";
153
+ };
154
+ return (chunk) => {
155
+ const keys: Key[] = [];
156
+ let i = 0;
157
+ if (pasting !== undefined && now() - lastByteAt > idleMs) {
158
+ leavePaste();
159
+ draining = true;
160
+ drained = 0;
161
+ }
162
+ if (draining && drained > MAX_PASTE && now() - lastByteAt > idleMs) {
163
+ // Past the bound AND silent for idleMs: the end marker is lost, hand the input back.
164
+ draining = false;
165
+ carry = "";
166
+ }
167
+ if (draining) {
168
+ lastByteAt = now();
169
+ const text = carry + chunk;
170
+ const lead = carry.length;
171
+ carry = "";
172
+ const end = text.indexOf(PASTE_END);
173
+ const ctrl = text.indexOf("\x03");
174
+ if (ctrl !== -1 && (end === -1 || ctrl < end)) {
175
+ draining = false;
176
+ keys.push({ name: "interrupt" });
177
+ i = ctrl - lead + 1;
178
+ } else if (end !== -1) {
179
+ draining = false;
180
+ // Discarded content yields no key of its own, so mark the end: whatever follows in this
181
+ // chunk is then never a sole key.
182
+ keys.push({ name: "ignored" });
183
+ i = end - lead + PASTE_END.length;
184
+ } else {
185
+ // Past the bound the drain ends only after idleMs of silence (checked above), so a
186
+ // still-flowing paste is never typed.
187
+ drained += chunk.length;
188
+ carry = markerTail(text);
189
+ return keys;
190
+ }
191
+ }
192
+ while (i < chunk.length) {
193
+ if (pasting !== undefined) {
194
+ lastByteAt = now();
195
+ const text = carry + chunk.slice(i);
196
+ const lead = carry.length;
197
+ carry = "";
198
+ const end = text.indexOf(PASTE_END);
199
+ const ctrl = text.indexOf("\x03");
200
+ if (ctrl !== -1 && (end === -1 || ctrl < end)) {
201
+ // Ctrl-C always gets out: the paste is dropped and the interrupt is delivered.
202
+ leavePaste();
203
+ keys.push({ name: "interrupt" });
204
+ i += ctrl - lead + 1;
205
+ continue;
206
+ }
207
+ const stop = end === -1 ? text.length - markerTail(text).length : end;
208
+ if (!overflow) {
209
+ pasting += text.slice(0, stop);
210
+ if (pasting.length > MAX_PASTE) {
211
+ overflow = true;
212
+ pasting = "";
213
+ }
214
+ }
215
+ if (end === -1) {
216
+ carry = text.slice(stop);
217
+ return keys;
218
+ }
219
+ keys.push(overflow ? { name: "ignored" } : { name: "paste", text: pasteText(pasting) });
220
+ leavePaste();
221
+ i += end - lead + PASTE_END.length;
222
+ continue;
223
+ }
224
+ const rest = chunk.slice(i);
225
+ if (rest.startsWith(PASTE_START)) {
226
+ pasting = "";
227
+ lastByteAt = now();
228
+ i += PASTE_START.length;
229
+ continue;
230
+ }
231
+ const hit = NAMED.find(([seq]) => rest.startsWith(seq));
232
+ if (hit !== undefined) {
233
+ keys.push({ name: hit[1] });
234
+ i += hit[0].length;
235
+ continue;
236
+ }
237
+ if (rest.startsWith("\x1b")) {
238
+ // A control string (OSC/DCS/SOS/PM/APC) is dropped WHOLE, payload included; so is a CSI/SS3.
239
+ const seq = CONTROL_STRING.exec(rest) ?? CSI.exec(rest) ?? SS3.exec(rest);
240
+ if (seq !== null) {
241
+ keys.push({ name: "ignored" });
242
+ i += seq[0].length;
243
+ continue;
244
+ }
245
+ keys.push({ name: "escape" });
246
+ i += 1;
247
+ continue;
248
+ }
249
+ const cp = rest.codePointAt(0) ?? 0;
250
+ const ch = String.fromCodePoint(cp);
251
+ i += ch.length;
252
+ // Controls, bidi and invisible marks are never typed text (core's one unsafe set).
253
+ keys.push(isPrintableChar(ch) ? { name: "char", char: ch } : { name: "ignored" });
254
+ }
255
+ return keys;
256
+ };
257
+ }
258
+
259
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC begins the sequences being matched
260
+ const CONTROL_STRING = /^\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)?/;
261
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC begins the sequences being matched
262
+ const CSI = /^\x1b\[[0-9;?]*[ -/]*[@-~]/;
263
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC begins the sequences being matched
264
+ const SS3 = /^\x1bO[@-~]/;
265
+
266
+ /**
267
+ * Split a chunk of terminal input into keys. Unknown escape sequences (arrows left/right, function
268
+ * keys, mouse reports, OSC strings) are dropped whole rather than leaking their bytes in as
269
+ * characters; a lone ESC is `escape`. Printable code points become `char` keys; a bracketed paste
270
+ * (`ESC[200~ … ESC[201~`) is ONE `paste` key, text only.
271
+ */
272
+ export function decodeKeys(chunk: string): Key[] {
273
+ return createKeyDecoder()(chunk).filter((k) => k.name !== "ignored");
274
+ }
275
+
276
+ /** One changed line of a repaint. `text: undefined` = the line no longer exists. */
277
+ export interface LinePatch {
278
+ /** 0-based line index within the frame. */
279
+ readonly line: number;
280
+ readonly text: string | undefined;
281
+ }
282
+
283
+ /** The lines that differ between two frames, in order. Equal frames diff to nothing. */
284
+ export function diffFrames(prev: readonly string[], next: readonly string[]): LinePatch[] {
285
+ const out: LinePatch[] = [];
286
+ const n = Math.max(prev.length, next.length);
287
+ for (let i = 0; i < n; i++) if (prev[i] !== next[i]) out.push({ line: i, text: next[i] });
288
+ return out;
289
+ }
290
+
291
+ const CLEAR_SCREEN = "\x1b[2J\x1b[H";
292
+ const at = (line: number): string => `\x1b[${line + 1};1H`;
293
+
294
+ /** The escape sequences that apply `patches`, then park the cursor under the frame. */
295
+ export function paintPatches(patches: readonly LinePatch[], nextLength: number): string {
296
+ if (patches.length === 0) return "";
297
+ const body = patches.map((p) => `${at(p.line)}\x1b[2K${p.text ?? ""}`).join("");
298
+ return `${body}${at(nextLength)}`;
299
+ }
300
+
301
+ /** The fixed words the controls footer uses beyond `LU_MESSAGES`: symbols, host-overridable. */
302
+ export interface InteractiveLabels {
303
+ /** The key legend under the controls. Default: symbols only (no words to translate). */
304
+ readonly hint?: string;
305
+ /** The marker before the focused control. Default `▸`. */
306
+ readonly focus?: string;
307
+ /** Shown after a first `y` / `n` when `confirmConsent` is on. Default: a symbols-only prompt. */
308
+ readonly confirm?: string;
309
+ }
310
+
311
+ export interface InteractiveOptions extends RenderExperienceOptions {
312
+ readonly store: HydrationStore;
313
+ /**
314
+ * Every `WidgetAction` a control emits, stamped with `viewedVersion` like the web's. It MAY return
315
+ * (a promise of) an `ActionOutcome`; nothing returned is success, `ok: false` / a throw is failure.
316
+ * No handler = "not available here": nothing is pretended to be sent.
317
+ */
318
+ readonly onAction?: (action: StampedAction) => unknown;
319
+ readonly input?: TerminalInput;
320
+ readonly output?: TerminalOutput;
321
+ /** Called after each repaint with what changed (the incremental-render evidence). */
322
+ readonly onPaint?: (stats: PaintStats) => void;
323
+ /** Called on Ctrl-C. Default: `stop()`. */
324
+ readonly onInterrupt?: () => void;
325
+ readonly labels?: InteractiveLabels;
326
+ /** The compile fingerprint stamped on every action (`viewedVersion.compile`). */
327
+ readonly compile?: string;
328
+ /**
329
+ * Require a SECOND, deliberate key for an approval: the first `y` / `n` only arms it (the frame
330
+ * says "press again"), and the same key, alone in a LATER chunk on the same frame, acts. Any other
331
+ * key disarms it. Default off. RECOMMENDED when the host reads a raw byte stream (see the known
332
+ * limits on `interactiveTerminal`).
333
+ */
334
+ readonly confirmConsent?: boolean;
335
+ /** The clock (ms) the bracketed-paste idle bound reads. Default `Date.now`; injected by tests. */
336
+ readonly now?: () => number;
337
+ /** A bracketed paste with no byte for this long is discarded. Default 2000. */
338
+ readonly pasteIdleMs?: number;
339
+ }
340
+
341
+ export interface PaintStats {
342
+ /** `full` is the first paint (or a forced one); `patch` rewrote only the changed lines. */
343
+ readonly mode: "full" | "patch" | "none";
344
+ readonly changedLines: number;
345
+ readonly totalLines: number;
346
+ }
347
+
348
+ export type ControlKind = "approval" | "select" | "multi" | "text";
349
+ export interface Control {
350
+ readonly id: string;
351
+ readonly kind: ControlKind;
352
+ readonly label: string;
353
+ }
354
+
355
+ export interface InteractiveTerminal {
356
+ /** The current frame: the snapshot, then the controls footer when there are controls. */
357
+ frame(): string;
358
+ /** The controls in reading order, with the focused one's id. */
359
+ controls(): readonly Control[];
360
+ focused(): string | undefined;
361
+ /** Feed raw input (what a TTY would deliver). Resolves when every effect, incl. an action, has settled. */
362
+ send(chunk: string): Promise<void>;
363
+ /** Feed decoded keys. */
364
+ press(...keys: Key[]): Promise<void>;
365
+ /** Land a revised Blueprint: only the lines that changed are repainted; focus follows the control's id. */
366
+ update(next: Blueprint): void;
367
+ /** Detach from the input, the store and the output. Idempotent. */
368
+ stop(): void;
369
+ }
370
+
371
+ type Mode =
372
+ | { readonly kind: "nav" }
373
+ | { readonly kind: "text"; readonly draft: string }
374
+ | { readonly kind: "pick"; readonly index: number }
375
+ | { readonly kind: "multi"; readonly index: number; readonly picks: readonly string[] };
376
+
377
+ /** Every widget under `layout`, in document order (a grid child is a `GridCell` holding its node). */
378
+ function widgetsOf(layout: Layout): Widget[] {
379
+ const out: Widget[] = [];
380
+ const visit = (node: Widget | Layout): void => {
381
+ if ("_tag" in node) {
382
+ out.push(node);
383
+ return;
384
+ }
385
+ for (const child of node.children) visit("node" in child ? child.node : child);
386
+ };
387
+ visit(layout);
388
+ return out;
389
+ }
390
+
391
+ const propLabel = (raw: unknown): string =>
392
+ typeof raw === "string" ? oneLineChatText(raw, 80) : "";
393
+
394
+ /**
395
+ * Run the renderer as a live, keyboard-driven session.
396
+ *
397
+ * Consent (`y` / `n`, a submit) acts only as the SOLE unit of an input chunk: every decoded unit
398
+ * counts, dropped escape sequences and non-printables included. KNOWN LIMITS, because a chunk
399
+ * boundary is all the terminal gives us: (1) a terminal WITHOUT bracketed paste delivers a one-char
400
+ * paste (`y`) as a one-key chunk, indistinguishable from a keypress; (2) a host that reads byte by
401
+ * byte (slow serial / ssh) makes every key a one-key chunk, so a multi-line non-bracketed paste can
402
+ * reach `\t`, `y`. For such hosts turn on `confirmConsent`, which asks for the same key again in a
403
+ * later chunk. Default off.
404
+ */
405
+ export function interactiveTerminal(
406
+ initial: Blueprint,
407
+ opts: InteractiveOptions
408
+ ): InteractiveTerminal {
409
+ const { store, onAction, input, output, onPaint, labels, compile } = opts;
410
+ const say = createSay(opts.i18n as LuI18n | undefined);
411
+ const focusMark = labels?.focus ?? "▸";
412
+ const hint = labels?.hint ?? "⇥ ⏎ ⎋";
413
+ const renderOpts: RenderExperienceOptions = (({
414
+ store: _s,
415
+ onAction: _a,
416
+ input: _i,
417
+ output: _o,
418
+ onPaint: _p,
419
+ onInterrupt: _t,
420
+ labels: _l,
421
+ compile: _c,
422
+ confirmConsent: _cc,
423
+ now: _n,
424
+ pasteIdleMs: _pi,
425
+ ...rest
426
+ }) => rest)(opts);
427
+
428
+ let blueprint = initial;
429
+ let focusId: string | undefined;
430
+ let mode: Mode = { kind: "nav" };
431
+ let note = "";
432
+ let previous: string[] | undefined;
433
+ let stopped = false;
434
+ let current = "";
435
+ let queue: Promise<void> = Promise.resolve();
436
+ /** The APPROVE/REJECT already sent for a pending approval: further y/n are ignored until the frame moves. */
437
+ let armed: { readonly id: string; readonly fp: string; readonly char: string } | undefined;
438
+ let answered: { readonly id: string; readonly fp: string } | undefined;
439
+
440
+ const boundValue = (w: Widget): unknown => {
441
+ const p = w.binding?.path;
442
+ if (p === undefined || !p.startsWith(`${HYDRATION_VALUES_PREFIX}.`)) return undefined;
443
+ return store.value(p.slice(HYDRATION_VALUES_PREFIX.length + 1));
444
+ };
445
+
446
+ const statusOf = (w: Widget): { state: string; seq?: number } | undefined => {
447
+ if (w.binding === undefined) return undefined;
448
+ const st = store.status(w.binding.path.slice(HYDRATION_VALUES_PREFIX.length + 1));
449
+ return st === undefined
450
+ ? undefined
451
+ : { state: st.state, ...(st.seq !== undefined && { seq: st.seq }) };
452
+ };
453
+
454
+ /** What the viewer was looking at when a key ARRIVED: the blueprint id and every control's binding status. */
455
+ interface Snapshot {
456
+ readonly id: string;
457
+ readonly statuses: ReadonlyMap<string, { state: string; seq?: number } | undefined>;
458
+ readonly fp: string;
459
+ }
460
+ function capture(): Snapshot {
461
+ const statuses = new Map<string, { state: string; seq?: number } | undefined>();
462
+ const parts: unknown[] = [blueprint.id];
463
+ for (const c of controlsNow()) {
464
+ const st = statusOf(c.widget);
465
+ statuses.set(c.id, st);
466
+ parts.push([c.id, c.kind, st?.state, st?.seq]);
467
+ }
468
+ return { id: blueprint.id, statuses, fp: JSON.stringify(parts) };
469
+ }
470
+
471
+ function controlsNow(): Array<Control & { widget: Widget }> {
472
+ const out: Array<Control & { widget: Widget }> = [];
473
+ for (const w of widgetsOf(blueprint.layout)) {
474
+ const label = propLabel(w.props.label) || propLabel(w.title);
475
+ if (w.kind === "ApprovalCard") {
476
+ // Like the web: no pending approval, no controls.
477
+ if (boundValue(w))
478
+ out.push({
479
+ id: w.id,
480
+ kind: "approval",
481
+ label: label || say("lu.approval.approve"),
482
+ widget: w,
483
+ });
484
+ } else if (w.kind === "SelectInput") {
485
+ if (optionsProblem(w.props.options) === undefined)
486
+ out.push({ id: w.id, kind: "select", label: label || say("lu.input.choose"), widget: w });
487
+ } else if (w.kind === "MultiSelectInput") {
488
+ if (multiSelectValueIssues(boundValue(w)).length === 0)
489
+ out.push({ id: w.id, kind: "multi", label: label || say("lu.input.choose"), widget: w });
490
+ } else if (w.kind === "TextInput") {
491
+ out.push({ id: w.id, kind: "text", label: label || say("lu.input.message"), widget: w });
492
+ }
493
+ }
494
+ return out;
495
+ }
496
+
497
+ const optionsOf = (w: Widget): ReadonlyArray<{ value: string; label: string }> =>
498
+ optionsProblem(w.props.options) === undefined
499
+ ? (w.props.options as ReadonlyArray<{ value: string; label: string }>)
500
+ : [];
501
+
502
+ const multiOf = (w: Widget): MultiSelectValue | undefined => {
503
+ const v = boundValue(w);
504
+ return multiSelectValueIssues(v).length === 0 ? (v as MultiSelectValue) : undefined;
505
+ };
506
+ const maxOf = (w: Widget): number => {
507
+ const m = w.props.maxSelected;
508
+ return typeof m === "number" && Number.isInteger(m) && m >= 1 && m <= MAX_MULTI_SELECTED
509
+ ? m
510
+ : MAX_MULTI_SELECTED;
511
+ };
512
+
513
+ /** The checklist's cursor and ticks, held to the CURRENT options and cap: what is drawn is what a send uses. */
514
+ const reconcile = (
515
+ w: Widget,
516
+ index: number,
517
+ picks: readonly string[]
518
+ ): { index: number; picks: string[] } => {
519
+ const m = multiOf(w);
520
+ const have = new Set((m?.options ?? []).map((o) => o.value));
521
+ const kept = [...new Set(picks)].filter((p) => have.has(p)).slice(0, maxOf(w));
522
+ const last = Math.max(0, (m?.options.length ?? 1) - 1);
523
+ return { index: Math.min(Math.max(0, index), last), picks: kept };
524
+ };
525
+
526
+ function build(): string {
527
+ const body = renderExperience(blueprint, store, renderOpts);
528
+ const controls = controlsNow();
529
+ if (controls.length === 0) return inert(body);
530
+ if (focusId === undefined || !controls.some((c) => c.id === focusId)) {
531
+ focusId = controls[0]?.id;
532
+ mode = { kind: "nav" };
533
+ }
534
+ const lines: string[] = ["─".repeat(24)];
535
+ for (const c of controls) {
536
+ const focused = c.id === focusId;
537
+ const mark = focused ? focusMark : " ";
538
+ if (c.kind === "approval") {
539
+ lines.push(
540
+ `${mark} ${c.label} [y] ${say("lu.approval.approve")} [n] ${say("lu.approval.reject")}`
541
+ );
542
+ } else if (c.kind === "text") {
543
+ const editing = focused && mode.kind === "text";
544
+ lines.push(
545
+ `${mark} ${c.label}: ${editing ? `${(mode as { draft: string }).draft}▏` : "…"}`
546
+ );
547
+ } else if (c.kind === "multi") {
548
+ lines.push(`${mark} ${c.label}`);
549
+ const m = multiOf(c.widget);
550
+ if (focused && mode.kind === "multi" && m !== undefined) {
551
+ const rec = reconcile(c.widget, mode.index, mode.picks);
552
+ const at = rec.index;
553
+ const from = Math.max(0, Math.min(at - 4, m.options.length - 10));
554
+ const picks = new Set(rec.picks);
555
+ m.options.slice(from, from + 10).forEach((o, k) => {
556
+ const i = from + k;
557
+ lines.push(
558
+ ` ${i === at ? "›" : " "} [${picks.has(o.value) ? "x" : " "}] ${oneLineChatText(o.label, 60)}`
559
+ );
560
+ });
561
+ }
562
+ } else {
563
+ lines.push(`${mark} ${c.label}`);
564
+ const w = controls.find((x) => x.id === c.id)?.widget;
565
+ if (focused && mode.kind === "pick" && w !== undefined) {
566
+ const at = mode.index;
567
+ optionsOf(w).forEach((o, i) =>
568
+ lines.push(` ${i === at ? "›" : " "} ${i + 1}) ${oneLineChatText(o.label, 60)}`)
569
+ );
570
+ }
571
+ }
572
+ }
573
+ if (note !== "") lines.push(note);
574
+ lines.push(hint);
575
+ // The WHOLE frame is made inert, footer included: the i18n words, the hint and focus marks, and
576
+ // the draft echo are as host- or viewer-authored as any title.
577
+ return inert(`${body}${lines.join("\n")}\n`);
578
+ }
579
+
580
+ function paint(force = false): void {
581
+ if (stopped) return;
582
+ current = build();
583
+ const next = current.replace(/\n$/, "").split("\n");
584
+ const prev = previous;
585
+ previous = next;
586
+ if (output === undefined) return;
587
+ if (prev === undefined || force) {
588
+ output.write(`${CLEAR_SCREEN}${next.join("\n")}\n`);
589
+ onPaint?.({ mode: "full", changedLines: next.length, totalLines: next.length });
590
+ return;
591
+ }
592
+ const patches = diffFrames(prev, next);
593
+ if (patches.length > 0) output.write(paintPatches(patches, next.length));
594
+ onPaint?.({
595
+ mode: patches.length === 0 ? "none" : "patch",
596
+ changedLines: patches.length,
597
+ totalLines: next.length,
598
+ });
599
+ }
600
+
601
+ async function dispatch(
602
+ action: { type: string; widgetId: string; payload?: unknown },
603
+ w: Widget,
604
+ snap: Snapshot
605
+ ) {
606
+ if (onAction === undefined) {
607
+ note = say("lu.chat.dispatch.unavailable");
608
+ return false;
609
+ }
610
+ // Stamped from what the viewer saw when the key ARRIVED, not from whatever is current by the
611
+ // time a queued key runs.
612
+ const status = snap.statuses.get(w.id);
613
+ const stamped: StampedAction = {
614
+ ...action,
615
+ viewedVersion: viewedVersionOf(snap.id, status, compile),
616
+ };
617
+ note = say("lu.chat.sending");
618
+ paint();
619
+ try {
620
+ const out = (await onAction(stamped)) as Partial<ActionOutcome> | null | undefined;
621
+ if (typeof out === "object" && out !== null && out.ok === false) {
622
+ note = say(out.reason === "stale" ? "lu.chat.dispatch.stale" : "lu.chat.dispatch.failed");
623
+ return false;
624
+ }
625
+ note = say("lu.input.sent");
626
+ return true;
627
+ } catch {
628
+ note = say("lu.chat.dispatch.failed");
629
+ return false;
630
+ }
631
+ }
632
+
633
+ /** `"stop"` = drop the rest of this input chunk (a submit, a refusal, or a consent key that was not alone). */
634
+ type Outcome = "stop" | undefined;
635
+
636
+ /**
637
+ * A consent key (y/n on an approval, any submit) acts only when it is the SOLE key of its input
638
+ * chunk and the frame it was pressed on is still the current one. Otherwise it is refused.
639
+ */
640
+ function consent(sole: boolean, snap: Snapshot): boolean {
641
+ if (!sole) return false;
642
+ if (snap.fp !== capture().fp) {
643
+ note = say("lu.chat.dispatch.stale");
644
+ return false;
645
+ }
646
+ return true;
647
+ }
648
+
649
+ async function handle(key: Key, sole: boolean, snap: Snapshot): Promise<Outcome> {
650
+ // Any key but the confirming one disarms; the confirming key is checked below.
651
+ const wasArmed = armed;
652
+ armed = undefined;
653
+ if (key.name === "interrupt") {
654
+ (opts.onInterrupt ?? stop)();
655
+ return "stop";
656
+ }
657
+ if (key.name === "char" && !isPrintableChar(key.char)) return undefined;
658
+ const controls = controlsNow();
659
+ const i = controls.findIndex((c) => c.id === focusId);
660
+ const c = controls[i];
661
+ if (c === undefined) return undefined;
662
+ if (mode.kind === "text") {
663
+ if (key.name === "escape") mode = { kind: "nav" };
664
+ else if (key.name === "backspace")
665
+ mode = { kind: "text", draft: Array.from(mode.draft).slice(0, -1).join("") };
666
+ else if (key.name === "char") mode = { kind: "text", draft: mode.draft + (key.char ?? "") };
667
+ else if (key.name === "paste") mode = { kind: "text", draft: mode.draft + (key.text ?? "") };
668
+ else if (key.name === "enter") {
669
+ if (!consent(sole, snap)) return "stop";
670
+ const v = validateTextValue(mode.draft);
671
+ if (!v.ok) {
672
+ note = v.error === "too-long" ? say("lu.input.error.tooLong") : say("lu.chat.typeFirst");
673
+ } else {
674
+ const ok = await dispatch(
675
+ {
676
+ type: SUBMIT_TEXT_ACTION,
677
+ widgetId: c.id,
678
+ payload: { text: v.text, key: newSubmitKey() },
679
+ },
680
+ c.widget,
681
+ snap
682
+ );
683
+ // Like the web: what was typed is kept until the host says it arrived.
684
+ if (ok) mode = { kind: "nav" };
685
+ return "stop";
686
+ }
687
+ }
688
+ return undefined;
689
+ }
690
+ if (mode.kind === "pick") {
691
+ const options = optionsOf(c.widget);
692
+ let choose: number | undefined;
693
+ if (key.name === "escape") mode = { kind: "nav" };
694
+ else if (key.name === "up") mode = { kind: "pick", index: Math.max(0, mode.index - 1) };
695
+ else if (key.name === "down")
696
+ mode = { kind: "pick", index: Math.min(options.length - 1, mode.index + 1) };
697
+ else if (key.name === "enter") choose = mode.index;
698
+ else if (key.name === "char" && key.char !== undefined && /^[1-9]$/.test(key.char))
699
+ choose = Number(key.char) - 1;
700
+ if (choose !== undefined) {
701
+ const picked = options[choose];
702
+ const v = picked === undefined ? undefined : validateSelectValue(options, picked.value);
703
+ if (v?.ok === true) {
704
+ if (!consent(sole, snap)) return "stop";
705
+ const ok = await dispatch(
706
+ {
707
+ type: SELECT_ACTION,
708
+ widgetId: c.id,
709
+ payload: { value: v.value, key: newSubmitKey() },
710
+ },
711
+ c.widget,
712
+ snap
713
+ );
714
+ if (ok) mode = { kind: "nav" };
715
+ return "stop";
716
+ }
717
+ }
718
+ return undefined;
719
+ }
720
+ if (mode.kind === "multi") {
721
+ const m = multiOf(c.widget);
722
+ if (m === undefined) {
723
+ mode = { kind: "nav" };
724
+ return undefined;
725
+ }
726
+ mode = { kind: "multi", ...reconcile(c.widget, mode.index, mode.picks) };
727
+ const last = m.options.length - 1;
728
+ if (key.name === "escape") mode = { kind: "nav" };
729
+ else if (key.name === "up") mode = { ...mode, index: Math.max(0, mode.index - 1) };
730
+ else if (key.name === "down") mode = { ...mode, index: Math.min(last, mode.index + 1) };
731
+ else if (key.name === "char" && key.char === " ") {
732
+ const o = m.options[mode.index];
733
+ if (o !== undefined) {
734
+ const has = mode.picks.includes(o.value);
735
+ if (has) mode = { ...mode, picks: mode.picks.filter((p) => p !== o.value) };
736
+ else if (mode.picks.length < maxOf(c.widget))
737
+ mode = { ...mode, picks: [...mode.picks, o.value] };
738
+ }
739
+ } else if (key.name === "enter") {
740
+ if (!consent(sole, snap)) return "stop";
741
+ const ok = await dispatch(
742
+ {
743
+ type: SELECT_MANY_ACTION,
744
+ widgetId: c.id,
745
+ payload: { values: [...mode.picks], key: newSubmitKey() },
746
+ },
747
+ c.widget,
748
+ snap
749
+ );
750
+ if (ok) mode = { kind: "nav" };
751
+ return "stop";
752
+ }
753
+ return undefined;
754
+ }
755
+ // Navigation. A paste is text: with no draft open it is nothing.
756
+ if (key.name === "paste") return undefined;
757
+ note = "";
758
+ const move = (d: number): void => {
759
+ focusId = controls[(i + d + controls.length) % controls.length]?.id;
760
+ };
761
+ if (key.name === "tab" || key.name === "down") move(1);
762
+ else if (key.name === "shift-tab" || key.name === "up") move(-1);
763
+ else if (
764
+ c.kind === "approval" &&
765
+ key.name === "char" &&
766
+ (key.char === "y" || key.char === "n")
767
+ ) {
768
+ if (!consent(sole, snap)) return "stop";
769
+ // One answer per pending approval: until the frame moves (the host answered, or a new
770
+ // version landed) a second y/n is ignored, so a repeat cannot double-submit.
771
+ if (answered !== undefined && answered.id === c.id && answered.fp === snap.fp) return "stop";
772
+ if (
773
+ opts.confirmConsent === true &&
774
+ !(wasArmed?.id === c.id && wasArmed.fp === snap.fp && wasArmed.char === key.char)
775
+ ) {
776
+ const char = key.char ?? "";
777
+ armed = { id: c.id, fp: snap.fp, char };
778
+ note = labels?.confirm ?? `${char} ×2`;
779
+ return "stop";
780
+ }
781
+ const approved = key.char === "y";
782
+ const ok = await dispatch(
783
+ { type: approved ? "APPROVE" : "REJECT", widgetId: c.id, payload: { approved } },
784
+ c.widget,
785
+ snap
786
+ );
787
+ answered = ok ? { id: c.id, fp: snap.fp } : undefined;
788
+ return "stop";
789
+ } else if (key.name === "enter") {
790
+ if (c.kind === "text") mode = { kind: "text", draft: "" };
791
+ else if (c.kind === "select") mode = { kind: "pick", index: 0 };
792
+ else if (c.kind === "multi")
793
+ mode = { kind: "multi", ...reconcile(c.widget, 0, multiOf(c.widget)?.selected ?? []) };
794
+ }
795
+ return undefined;
796
+ }
797
+
798
+ const run = (work: () => Promise<void>): Promise<void> => {
799
+ queue = queue.then(work, work);
800
+ return queue;
801
+ };
802
+ /** One INPUT CHUNK: the keys the terminal delivered together (a paste arrives this way). */
803
+ async function chunkOf(keys: Key[]): Promise<void> {
804
+ // The frame the keys were pressed on is captured NOW, before they queue behind anything.
805
+ const snap = capture();
806
+ const sole = keys.length === 1;
807
+ await run(async () => {
808
+ for (const k of keys) {
809
+ if (stopped) return;
810
+ const before = mode.kind;
811
+ const out = await handle(k, sole, snap);
812
+ paint();
813
+ // A key that changed mode or submitted ends the chunk: what follows in the SAME chunk was
814
+ // typed (or pasted) for the old mode, never for the new one.
815
+ if (out === "stop" || mode.kind !== before) return;
816
+ }
817
+ });
818
+ }
819
+ /** Programmatic keys: each is its own arrival, in order (a caller pressing keys means each one). */
820
+ async function press(...keys: Key[]): Promise<void> {
821
+ await Promise.all(keys.map((k) => chunkOf([k])));
822
+ }
823
+
824
+ const decode = createKeyDecoder({
825
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
826
+ ...(opts.pasteIdleMs !== undefined ? { pasteIdleMs: opts.pasteIdleMs } : {}),
827
+ });
828
+ const onData = (chunk: string | Uint8Array): void => {
829
+ void chunkOf(decode(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")));
830
+ };
831
+
832
+ // Bracketed paste is on while a session reads input AND paints, so a paste arrives wrapped.
833
+ const pasteMode = input !== undefined && output !== undefined;
834
+ const stops: Array<() => void> = [];
835
+ function stop(): void {
836
+ if (stopped) return;
837
+ stopped = true;
838
+ for (const s of stops.splice(0)) s();
839
+ if (pasteMode) output?.write(PASTE_OFF);
840
+ }
841
+
842
+ stops.push(store.runtimeSubscribe(() => paint()));
843
+ if (opts.projectionSubscribe !== undefined) stops.push(opts.projectionSubscribe(() => paint()));
844
+ if (input !== undefined) {
845
+ input.setRawMode?.(true);
846
+ input.resume?.();
847
+ input.on("data", onData);
848
+ stops.push(() => {
849
+ (input.off ?? input.removeListener)?.call(input, "data", onData);
850
+ input.setRawMode?.(false);
851
+ });
852
+ }
853
+ if (pasteMode) output?.write(PASTE_ON);
854
+ paint();
855
+
856
+ return {
857
+ frame: () => current,
858
+ controls: () => controlsNow().map(({ id, kind, label }) => ({ id, kind, label })),
859
+ focused: () => focusId,
860
+ send: (chunk) => chunkOf(decode(chunk)),
861
+ press,
862
+ update(next) {
863
+ blueprint = next;
864
+ paint();
865
+ },
866
+ stop,
867
+ };
868
+ }