@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,630 @@
1
+ import { createHash } from "node:crypto";
2
+ import { RendererRegistry, type Widget } from "@loomwork/blueprint-runtime";
3
+ import {
4
+ type TerminalWidgetRenderer,
5
+ defaultTerminalRegistry,
6
+ } from "@loomwork/plugin-renderer-terminal";
7
+ import {
8
+ type BarChartValue,
9
+ type ChatValue,
10
+ DELTA_FORMAT,
11
+ type DiagramValue,
12
+ type KpiValue,
13
+ LocalizedText,
14
+ type LuI18n,
15
+ type LuMessageKey,
16
+ MD_MAX_LINES,
17
+ type MediaKind,
18
+ type MediaPolicy,
19
+ type MediaRef,
20
+ type ProseValue,
21
+ type Say,
22
+ type SemanticFormat,
23
+ type StatusCardValue,
24
+ type TableCell,
25
+ type TableValue,
26
+ type TimeSeriesValue,
27
+ chatMarkdownLines,
28
+ chatTranscriptLines,
29
+ checkKindValue,
30
+ checkMediaUrl,
31
+ cleanMediaText,
32
+ createSay,
33
+ deltaDirection,
34
+ describeMedia,
35
+ diagramOutline,
36
+ effectiveFormat,
37
+ fieldsProblem,
38
+ formatSemanticNumber,
39
+ isActiveMediaType,
40
+ isSameOriginMediaUrl,
41
+ lookupMessage,
42
+ maxBytesFor,
43
+ oneLineChatText,
44
+ optionsProblem,
45
+ pluralForms,
46
+ selectPlural,
47
+ tableCellText,
48
+ tableColumnFormat,
49
+ tableColumnLabel,
50
+ unitSuffix,
51
+ widgetRefsOf,
52
+ } from "@loomwork/ui-core";
53
+ import { type MultiSelectValue, multiSelectValueIssues } from "@loomwork/ui-core/chat-ui";
54
+ import {
55
+ MD_DOC_MAX_TEXT,
56
+ parseMarkdownDocument,
57
+ parseOrgDocument,
58
+ } from "@loomwork/ui-core/document";
59
+ import { inert } from "./inert";
60
+
61
+ const LEVEL_KEY = {
62
+ ok: "lu.level.ok",
63
+ warning: "lu.level.warning",
64
+ critical: "lu.level.critical",
65
+ } as const satisfies Record<string, LuMessageKey>;
66
+
67
+ const SPARK = "▁▂▃▄▅▆▇█";
68
+ /**
69
+ * Who draws a widget's title: `"widget"` (default) prints `Title: …` in the body; `"host"` leaves
70
+ * the title to the embedding app's own chrome (a title bar, a frame), so the body carries none —
71
+ * error and empty renderings included. One rule for every kind either way.
72
+ */
73
+ export type TitleChrome = "widget" | "host";
74
+
75
+ /**
76
+ * What a terminal can do about media (U9). A terminal loads nothing, so there is no resolver: it
77
+ * prints WHAT the media is (name, type, size) and a way to name it. Media is shown as untrusted
78
+ * unless the host says otherwise, exactly as the web surface does without a vouching resolver.
79
+ */
80
+ export interface TerminalMediaOptions {
81
+ /**
82
+ * A SYNCHRONOUS viewer-scoped link for a ref (e.g. a short-lived signed URL the host minted for
83
+ * this session), or `undefined` for none. It is re-checked with `checkMediaUrl` before printing;
84
+ * a refused link prints nothing. Without one the line carries a non-reversible tag only, never any part of the
85
+ * address (an address is a capability, and terminal output is copied and logged).
86
+ */
87
+ readonly link?: (ref: MediaRef, kind: MediaKind) => string | undefined;
88
+ /** Whether the HOST vouches for this ref's bytes. Default: nothing is trusted. User-origin media never is. */
89
+ readonly trusted?: (ref: MediaRef, kind: MediaKind) => boolean;
90
+ /** The same display policy the web surface takes (size limits, URL rules). */
91
+ readonly policy?: MediaPolicy;
92
+ }
93
+
94
+ export interface TerminalRegistryOptions {
95
+ /** Who draws each widget's title. Default `"widget"` — today's output, unchanged. */
96
+ readonly chrome?: TitleChrome;
97
+ /** How media prints. Default: handle only, everything untrusted. */
98
+ readonly media?: TerminalMediaOptions;
99
+ /**
100
+ * The host override for every fixed word the terminal prints (see `LU_MESSAGE_KEYS`) and the locale
101
+ * its numbers and plurals use. Default: English, locale `en-US` — see {@link DEFAULT_TERMINAL_LOCALE}.
102
+ */
103
+ readonly i18n?: LuI18n;
104
+ }
105
+
106
+ /** A non-empty string is a title; `""` (or a stray non-string) is none, so the kind stands in. */
107
+ const titleText = (w: Widget): string | undefined =>
108
+ typeof w.title === "string" && w.title !== "" ? w.title : undefined;
109
+ const title = (w: Widget): string => titleText(w) ?? w.kind;
110
+ const pad = (s: string, n: number): string => (s.length >= n ? s.slice(0, n) : s.padEnd(n));
111
+
112
+ /**
113
+ * The terminal's default locale for every number and plural: one fixed, deterministic tag, so the
114
+ * same value prints the same bytes on every machine (logs, snapshots, CI) instead of varying with the
115
+ * host's own locale. A host that wants another passes `i18n: { locale }`.
116
+ */
117
+ export const DEFAULT_TERMINAL_LOCALE = "en-US";
118
+
119
+ /** What a renderer needs to speak: the locale, and the host's words by key. */
120
+ interface Env {
121
+ readonly locale: string;
122
+ readonly say: Say;
123
+ /** A counted noun (`lu.plural.row`) in `locale`'s grammar when the host translated it, else English. */
124
+ readonly plural: (base: "lu.plural.row", n: number) => string;
125
+ }
126
+
127
+ function envOf(i18n: LuI18n | undefined): Env {
128
+ const locale = i18n?.locale ?? DEFAULT_TERMINAL_LOCALE;
129
+ return {
130
+ locale,
131
+ say: createSay(i18n),
132
+ plural: (base, n) =>
133
+ selectPlural(locale, base, n, pluralForms(base), (k, f) => lookupMessage(i18n, k, f)),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * A number exactly as the web's `measure()` would print it for the same value, format and unit —
139
+ * full numeric parity, not merely the same shape: an explicit `format` (or, absent one, a legacy
140
+ * `unit` read as semantics via `effectiveFormat`, the same "$"/"€"/"%"/ISO-4217 lookup the DOM
141
+ * renderer uses) goes through `@loomwork/ui-core`'s `formatSemanticNumber`, so a `currency` prints
142
+ * the web's own symbol ("€1,234,567.89"), never an ISO-code suffix, and a plain number groups and
143
+ * rounds exactly as `Intl.NumberFormat` does for the DOM renderer. The raw `unit` shows as a
144
+ * literal suffix unless the format draws its own symbol (currency, percentage) — core's
145
+ * `unitSuffix`, the same rule by which the DOM renderer shows it as a separate span. (renderer-lit is an ESM/lit-DOM package this CommonJS one
146
+ * cannot import, so this DOM-free part of its formatting logic lives in core instead of being
147
+ * re-derived here.)
148
+ */
149
+ function formatted(
150
+ x: Env,
151
+ v: number,
152
+ format: SemanticFormat | undefined,
153
+ unit: string | undefined
154
+ ): string {
155
+ const fmt = effectiveFormat(format, unit);
156
+ const text = formatSemanticNumber(v, fmt, x.locale);
157
+ const suffix = unitSuffix(fmt, unit);
158
+ return suffix === undefined ? text : `${text} ${suffix}`;
159
+ }
160
+
161
+ /**
162
+ * Min and max of the finite values, in one pass — never `Math.min(...values)` /
163
+ * `Math.max(...values)`, which spreads the whole array into call arguments and hits V8's
164
+ * argument-count ceiling on a long series. `undefined` iff no value is finite.
165
+ */
166
+ function finiteMinMax(values: readonly number[]): { min: number; max: number } | undefined {
167
+ let min = Number.POSITIVE_INFINITY;
168
+ let max = Number.NEGATIVE_INFINITY;
169
+ let seen = false;
170
+ for (const v of values) {
171
+ if (!Number.isFinite(v)) continue;
172
+ seen = true;
173
+ if (v < min) min = v;
174
+ if (v > max) max = v;
175
+ }
176
+ return seen ? { min, max } : undefined;
177
+ }
178
+
179
+ export function sparkline(values: readonly number[]): string {
180
+ const range = finiteMinMax(values);
181
+ if (range === undefined) return "";
182
+ const { min, max } = range;
183
+ return values
184
+ .map((v) =>
185
+ Number.isFinite(v)
186
+ ? SPARK[max === min ? 0 : Math.round(((v - min) / (max - min)) * (SPARK.length - 1))]
187
+ : " "
188
+ )
189
+ .join("");
190
+ }
191
+
192
+ /** Tag a body with its title — or leave it bare when the host draws the title. */
193
+ type Lead = (w: Widget, rest: string) => string;
194
+ const lead =
195
+ (host: boolean): Lead =>
196
+ (w, rest) =>
197
+ host ? rest : `${title(w)}: ${rest}`;
198
+
199
+ /**
200
+ * The hanging indent that sets a multi-line body's rows under its `Title:` lead. Host chrome has no
201
+ * lead to hang under, so it gets none (G18) — one helper, so every multi-line kind agrees.
202
+ */
203
+ const HANG = " ";
204
+ const hang = (host: boolean): string => (host ? "" : HANG);
205
+
206
+ type Body = (w: Widget, v: unknown, lead: Lead, host: boolean) => string;
207
+
208
+ const guarded =
209
+ (host: boolean, kind: string, body: Body): TerminalWidgetRenderer =>
210
+ (w, v) => {
211
+ const l = lead(host);
212
+ const check = checkKindValue(kind, v);
213
+ if (!check.ok) return `✗ ${l(w, check.finding.message)}`;
214
+ if (v === undefined) return l(w, "—");
215
+ const out = body(w, v, l, host);
216
+ const notes = refFootnotes(kind, v, host);
217
+ return notes === "" ? out : `${out}\n${notes}`;
218
+ };
219
+
220
+ /**
221
+ * `[1] label` per source the value cites, one line each under the widget (`[1]` alone without a label). The label is
222
+ * data, so it is cut to one clean line like any untrusted text; the numbering is the web's (array order from 1).
223
+ */
224
+ function refFootnotes(kind: string, v: unknown, host: boolean): string {
225
+ return widgetRefsOf(kind, v)
226
+ .map((r, i) => {
227
+ const label = r.label === undefined ? "" : oneLineChatText(r.label, 80);
228
+ return `${hang(host)}[${i + 1}]${label === "" ? "" : ` ${label}`}`;
229
+ })
230
+ .join("\n");
231
+ }
232
+
233
+ // The right-axis marker is semantic information (which axis a series reads against), so it must
234
+ // never be lost to the label column's fixed width. `pad(`${label}${marker}`, width)` — padding
235
+ // the already-concatenated string — truncates whichever part lands past the width, so a label
236
+ // alone at or beyond `width` swallows the marker whole. Truncate the LABEL alone to leave room
237
+ // for the marker, then append the marker after, so it always survives; a short label+marker
238
+ // still pads to `width` exactly as before, so every row's sparkline column stays aligned.
239
+ const RIGHT_AXIS_MARKER = " (R)";
240
+
241
+ function padLabelWithMarker(label: string, marker: string, width: number): string {
242
+ const combined = `${label}${marker}`;
243
+ if (combined.length <= width) return combined.padEnd(width);
244
+ if (marker === "") return combined.slice(0, width);
245
+ const truncatedLabel = label.slice(0, Math.max(width - marker.length, 0));
246
+ return `${truncatedLabel}${marker}`;
247
+ }
248
+
249
+ const timeSeries = (host: boolean, x: Env) =>
250
+ guarded(host, "TimeSeries", (w, v, l) => {
251
+ const { series } = v as TimeSeriesValue;
252
+ const props = w.props as { unit?: string; format?: SemanticFormat };
253
+ // Same rule as the web's lu-timeseries: a right axis exists only when some series is on the
254
+ // left to contrast it with; all-right falls back to one shared axis, so nothing is marked.
255
+ const splitAxes =
256
+ series.some((s) => s.axis !== "right") && series.some((s) => s.axis === "right");
257
+ const lines = series.map((s) => {
258
+ const values = s.points.map((p) => p.value);
259
+ const last = values.length > 0 ? (values[values.length - 1] as number) : Number.NaN;
260
+ // Same precedence as the web's `formatOf` (lu-timeseries.ts): this series' own format, else
261
+ // the widget's; a unit resolves only when neither format does.
262
+ const text = formatted(x, last, s.format ?? props.format, s.unit ?? props.unit);
263
+ const marker = splitAxes && s.axis === "right" ? RIGHT_AXIS_MARKER : "";
264
+ return `${hang(host)}${padLabelWithMarker(s.label, marker, 12)} ${sparkline(values)} ${text}`;
265
+ });
266
+ return (host ? lines : [`${title(w)}:`, ...lines]).join("\n");
267
+ });
268
+
269
+ const barChart = (host: boolean, x: Env) =>
270
+ guarded(host, "BarChart", (w, v, l) => {
271
+ const { bars, format, unit } = v as BarChartValue;
272
+ const props = w.props as { unit?: string; format?: SemanticFormat };
273
+ // Same precedence as the web's lu-bar-chart.ts: the chart's own value-level format, else the
274
+ // widget's props.format; a unit resolves only when neither format does. One format for every
275
+ // bar, exactly as the web computes it once and reuses it for every bar's `measure()` call.
276
+ const chartFormat = format ?? props.format;
277
+ const chartUnit = unit ?? props.unit;
278
+ // Single-pass max (never Math.max(...spread) — same argument-count hazard as sparkline's
279
+ // min/max), floored at 1 so an all-non-finite/zero series never divides by zero below.
280
+ let max = 1;
281
+ for (const b of bars) {
282
+ const abs = Math.abs(b.value);
283
+ if (Number.isFinite(abs) && abs > max) max = abs;
284
+ }
285
+ const lines = bars.map((b) => {
286
+ const filled = Number.isFinite(b.value) ? Math.round((Math.abs(b.value) / max) * 20) : 0;
287
+ const text = formatted(x, b.value, chartFormat, chartUnit);
288
+ return `${hang(host)}${pad(b.label, 12)} ${"█".repeat(filled)}${"·".repeat(20 - filled)} ${text}`;
289
+ });
290
+ return (host ? lines : [`${title(w)}:`, ...lines]).join("\n");
291
+ });
292
+
293
+ const kpi = (host: boolean, x: Env) =>
294
+ guarded(host, "Kpi", (w, v, l) => {
295
+ const k = v as KpiValue;
296
+ const props = w.props as { unit?: string; format?: SemanticFormat };
297
+ const delta = k.deltaPct ?? null;
298
+ const direction = delta === null ? undefined : deltaDirection(delta);
299
+ const arrow = direction === "up" ? "▲ " : direction === "down" ? "▼ " : "";
300
+ const change =
301
+ delta === null
302
+ ? ""
303
+ : ` ${arrow}${formatSemanticNumber(Math.abs(delta), DELTA_FORMAT, x.locale)}`;
304
+ // The label is a caption beside the title, as on the web — the title stays.
305
+ const text = `${formatted(x, k.value, k.format ?? props.format, k.unit ?? props.unit)}${change}`;
306
+ // Host-owned title: the caption (still information) trails the figure instead.
307
+ if (host) return k.label ? `${text} (${k.label})` : text;
308
+ return `${k.label ? `${title(w)} (${k.label})` : title(w)}: ${text}`;
309
+ });
310
+
311
+ /** Rows printed before the "… N more rows" line. */
312
+ const TABLE_ROWS = 20;
313
+ /** Widest a terminal column grows before its cells are cut. */
314
+ const TABLE_COL_MAX = 24;
315
+
316
+ const table = (host: boolean, x: Env) =>
317
+ guarded(host, "Table", (w, v, l) => {
318
+ const t = v as TableValue;
319
+ // Widths are computed over the text that will be PRINTED: loom's traversal strips controls
320
+ // from the whole frame afterwards, which would otherwise misalign a hostile cell.
321
+ const labels = t.columns.map((c) => inert(tableColumnLabel(c)));
322
+ const formats = t.columns.map(tableColumnFormat);
323
+ const cellText = (c: TableCell): string =>
324
+ c === null
325
+ ? "—"
326
+ : typeof c === "number"
327
+ ? formatted(x, c, undefined, undefined)
328
+ : inert(tableCellText(c));
329
+ const body = t.rows.slice(0, TABLE_ROWS).map((row) =>
330
+ labels.map((_, i) => {
331
+ const c = row[i] ?? null;
332
+ const f = formats[i];
333
+ return typeof c === "number" && f !== undefined
334
+ ? formatted(x, c, f, undefined)
335
+ : cellText(c);
336
+ })
337
+ );
338
+ // Single pass per column (never Math.max(...spread)); capped so one long cell cannot push
339
+ // every other column off the screen.
340
+ const widths = labels.map((label, i) => {
341
+ let width = label.length;
342
+ for (const row of body) width = Math.max(width, (row[i] ?? "").length);
343
+ return Math.min(width, TABLE_COL_MAX);
344
+ });
345
+ const line = (cells: readonly string[]): string =>
346
+ `${hang(host)}${cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ")}`.trimEnd();
347
+ const out = [...(host ? [] : [`${title(w)}:`]), line(labels), ...body.map(line)];
348
+ if (t.rows.length > TABLE_ROWS) {
349
+ const more = t.rows.length - TABLE_ROWS;
350
+ // The terminal's locale (see DEFAULT_TERMINAL_LOCALE) decides the plural, like its numbers.
351
+ out.push(
352
+ `${hang(host)}${x.say("lu.terminal.table.moreRows", { n: more, noun: x.plural("lu.plural.row", more) })}`
353
+ );
354
+ }
355
+ return out.join("\n");
356
+ });
357
+
358
+ const statusCard = (host: boolean, x: Env) =>
359
+ guarded(host, "StatusCard", (w, v, l) => {
360
+ const s = v as StatusCardValue;
361
+ return l(w, `[${x.say(LEVEL_KEY[s.level])}] ${s.headline}${s.detail ? ` — ${s.detail}` : ""}`);
362
+ });
363
+
364
+ // A document laid out as text: markdown and org through core's document parsers and `chatMarkdownLines` (which
365
+ // cleans every line and caps the total), plain text as its first lines. Hung under the title like Chat.
366
+ const prose = (host: boolean, x: Env) =>
367
+ guarded(host, "Prose", (w, v) => {
368
+ const p = v as ProseValue;
369
+ // Plain text over the document cap is cut (before it is split) and says so.
370
+ const over = p.format === "plain" && p.text.length > MD_DOC_MAX_TEXT;
371
+ const body =
372
+ p.text === ""
373
+ ? [x.say("lu.prose.empty")]
374
+ : p.format === "plain"
375
+ ? (over ? p.text.slice(0, MD_DOC_MAX_TEXT) : p.text)
376
+ .split("\n")
377
+ .slice(0, MD_MAX_LINES)
378
+ .map((l) => oneLineChatText(l, 200))
379
+ : chatMarkdownLines(
380
+ p.format === "org" ? parseOrgDocument(p.text) : parseMarkdownDocument(p.text)
381
+ );
382
+ const out = [
383
+ ...(p.note ? [oneLineChatText(p.note)] : []),
384
+ ...body,
385
+ ...(p.truncated || over ? [x.say("lu.prose.cut")] : []),
386
+ ];
387
+ const indent = hang(host);
388
+ return [...(host ? [] : [`${title(w)}:`]), ...out.map((l) => `${indent}${l}`)].join("\n");
389
+ });
390
+
391
+ // A diagram as text: core's shared outline (the same lines the web's hidden outline holds), one per
392
+ // line, hung under the title. Every label is untrusted data: cut to one clean line BEFORE the outline
393
+ // joins them, so no control or bidi character, and no newline, reaches the frame.
394
+ const diagram = (host: boolean, x: Env) =>
395
+ guarded(host, "Diagram", (w, v) => {
396
+ const d = v as DiagramValue;
397
+ const clean = (t: string): string => oneLineChatText(t, 200);
398
+ const checked: DiagramValue = {
399
+ ...d,
400
+ nodes: d.nodes.map((n) => ({
401
+ ...n,
402
+ label: clean(n.label),
403
+ ...(n.at === undefined ? {} : { at: n.at }),
404
+ })),
405
+ edges: d.edges.map((e) => ({
406
+ ...e,
407
+ ...(e.label === undefined ? {} : { label: clean(e.label) }),
408
+ })),
409
+ };
410
+ const indent = hang(host);
411
+ const body = diagramOutline(checked, x.locale, x.say).map(
412
+ (l) => `${indent}${" ".repeat(l.depth)}${l.text}`
413
+ );
414
+ return [
415
+ ...(host ? [] : [`${title(w)}:`]),
416
+ ...(d.title ? [`${indent}${clean(d.title)}`] : []),
417
+ ...body,
418
+ ].join("\n");
419
+ });
420
+
421
+ // Never bound, so never `guarded`: loom calls a channel-less widget's renderer directly, with
422
+ // `value === undefined`, and the text lives in props (a string after compile; an uncompiled
423
+ // LocalizedText shows its fallback, never its key).
424
+ const note =
425
+ (host: boolean, x: Env): TerminalWidgetRenderer =>
426
+ (w) => {
427
+ const raw = w.props.text;
428
+ const parsed = LocalizedText.safeParse(raw);
429
+ const text = typeof raw === "string" ? raw : parsed.success ? parsed.data.fallback : "";
430
+ // Only a non-empty string is a title — `""` and a stray `0` are none, as for every kind.
431
+ const t = titleText(w);
432
+ return t !== undefined && !host ? `${t}: ${text}` : text;
433
+ };
434
+
435
+ // The A2UI tree and the sandboxed component are web surfaces: an interpreter this CommonJS
436
+ // package cannot import, and code that cannot run in a terminal. Say so, don't fake it.
437
+ const a2uiView = (host: boolean, x: Env) =>
438
+ guarded(host, "A2UIView", (w, _v, l) => l(w, x.say("lu.terminal.a2ui")));
439
+ const sandboxView = (host: boolean, x: Env) =>
440
+ guarded(host, "SandboxView", (w, _v, l) => l(w, x.say("lu.terminal.sandbox")));
441
+
442
+ // ── Media (U9) ─────────────────────────────────────────────────────────────────
443
+ // `Image 'chart.png' (image/png, 1 KiB) [untrusted] ref#1a2b3c4d` — the SAME one-line
444
+ // description the web surface speaks (`describeMedia`), a trust marker, and a link or a non-reversible tag.
445
+ const mediaLine = (
446
+ host: boolean,
447
+ x: Env,
448
+ kind: MediaKind,
449
+ media: TerminalMediaOptions | undefined
450
+ ) =>
451
+ guarded(host, kind, (w, v, l) => {
452
+ const ref = v as MediaRef;
453
+ const policy = media?.policy;
454
+ const tooLarge = ref.size !== undefined && ref.size > maxBytesFor(kind, policy);
455
+ const trusted = ref.origin !== "user" && media?.trusted?.(ref, kind) === true;
456
+ // Untrusted active content (HTML, SVG, XML, script types) is never offered: no link, no handle.
457
+ const active = !trusted && isActiveMediaType(ref.mediaType);
458
+ const sameOrigin = (u: string): boolean =>
459
+ !trusted && isSameOriginMediaUrl(u, policy?.appOrigin);
460
+ const link = tooLarge || active ? undefined : media?.link?.(ref, kind);
461
+ const checked = checkMediaUrl(link, policy);
462
+ const offered =
463
+ checked.ok && !(sameOrigin(checked.url) && policy?.allowSameOriginUntrusted !== true)
464
+ ? checked.url
465
+ : undefined;
466
+ // A NON-REVERSIBLE tag, never any part of the address (a prefix of a capability is a partial
467
+ // capability, and terminal output is copied and logged). A usable link is the host's `media.link`.
468
+ const tag = `ref#${createHash("sha256").update(ref.address).digest("hex").slice(0, 8)}`;
469
+ const where = tooLarge
470
+ ? x.say("lu.terminal.media.tooLarge")
471
+ : active
472
+ ? x.say("lu.terminal.media.blocked")
473
+ : (offered ?? tag);
474
+ const alt = kind === "Image" ? cleanMediaText(ref.alt, 200) : "";
475
+ return l(
476
+ w,
477
+ `${describeMedia(kind, ref, x.say)}${alt === "" ? "" : ` — ${alt}`}${trusted ? "" : ` ${x.say("lu.terminal.untrusted")}`} ${where}`
478
+ );
479
+ });
480
+
481
+ // A media input is a web control (a file picker): say so, don't fake it. Static, so never `guarded`.
482
+ const mediaInput =
483
+ (host: boolean, x: Env): TerminalWidgetRenderer =>
484
+ (w) => {
485
+ const t = titleText(w);
486
+ const body = x.say("lu.terminal.fileUpload");
487
+ return t !== undefined && !host ? `${t}: ${body}` : body;
488
+ };
489
+
490
+ // ── Chat and inputs (U8) ───────────────────────────────────────────────────────
491
+ // The transcript prints as the SAME lines the web surface speaks (`chatTranscriptLines`: speaker,
492
+ // status note, fixed failure sentence + reference id, restricted entries as bare placeholders, media
493
+ // described without an address), under the title. Untrusted text is cleaned and bounded: a plain part is one
494
+ // line of at most `maxText` characters, a markdown part is up to 200 plain lines of at most 4 x `maxText`
495
+ // characters in all (`chatMarkdownLines`). A terminal escape (ESC), a C1 control or a bidi override in a turn
496
+ // cannot reach the screen.
497
+ const chatTranscript = (host: boolean, x: Env) =>
498
+ guarded(host, "Chat", (w, v) => {
499
+ const chat = v as ChatValue;
500
+ const lines = chatTranscriptLines(chat, { say: x.say });
501
+ const out: string[] = [];
502
+ if (chat.older !== undefined) out.push(x.say("lu.terminal.chat.earlier"));
503
+ out.push(...(lines.length === 0 ? [x.say("lu.terminal.chat.empty")] : lines));
504
+ if (chat.running === true && !chat.entries.some((e) => e.status === "streaming"))
505
+ out.push(x.say("lu.terminal.chat.replying"));
506
+ if (chat.state === "closed") out.push(x.say("lu.terminal.chat.closed"));
507
+ const indent = hang(host);
508
+ return [...(host ? [] : [`${title(w)}:`]), ...out.map((l) => `${indent}${l}`)].join("\n");
509
+ });
510
+
511
+ /** A prop that is text: a string, or an uncompiled LocalizedText's fallback; cleaned to one line. */
512
+ const propText = (raw: unknown): string => {
513
+ const parsed = LocalizedText.safeParse(raw);
514
+ const text = typeof raw === "string" ? raw : parsed.success ? parsed.data.fallback : "";
515
+ return oneLineChatText(text, 120);
516
+ };
517
+
518
+ // An input is a prompt in a terminal: say what it asks, so an interactive host can collect the answer
519
+ // (and send it through the same write-back as the web). Static, so never `guarded`.
520
+ const textPrompt =
521
+ (host: boolean, x: Env): TerminalWidgetRenderer =>
522
+ (w) => {
523
+ const label = propText(w.props.label) || x.say("lu.input.message");
524
+ const multi = w.props.multiline === true ? x.say("lu.terminal.multiline") : "";
525
+ const body = `> ${label}${multi} ${x.say("lu.terminal.textInput")}`;
526
+ return host ? body : `${title(w)}: ${body}`;
527
+ };
528
+
529
+ const formPrompt =
530
+ (host: boolean, x: Env): TerminalWidgetRenderer =>
531
+ (w) => {
532
+ const label = propText(w.props.label) || x.say("lu.input.form");
533
+ const fields =
534
+ fieldsProblem(w.props.fields) === undefined
535
+ ? (w.props.fields as ReadonlyArray<{
536
+ name: string;
537
+ label: string;
538
+ type: string;
539
+ required?: boolean;
540
+ }>)
541
+ : [];
542
+ const rows = fields.map(
543
+ (f) =>
544
+ `${hang(host)} ${oneLineChatText(f.label, 60)}${f.required === true ? "*" : ""} (${f.type})`
545
+ );
546
+ const head = `> ${label} ${x.say("lu.terminal.formInput")}`;
547
+ return [host ? head : `${title(w)}: ${head}`, ...rows].join("\n");
548
+ };
549
+
550
+ const selectPrompt =
551
+ (host: boolean, x: Env): TerminalWidgetRenderer =>
552
+ (w, v) => {
553
+ const label = propText(w.props.label) || x.say("lu.input.choose");
554
+ const options =
555
+ optionsProblem(w.props.options) === undefined
556
+ ? (w.props.options as ReadonlyArray<{ value: string; label: string }>)
557
+ : [];
558
+ // The bound current choice marks its option; a value that is not declared marks none and is never printed.
559
+ const rows = options.map(
560
+ (o, i) =>
561
+ `${hang(host)} ${i + 1}) ${oneLineChatText(o.label, 60)}${o.value === v ? ` ${x.say("lu.terminal.current")}` : ""}`
562
+ );
563
+ const head = `> ${label} ${x.say("lu.terminal.selectInput")}`;
564
+ return [host ? head : `${title(w)}: ${head}`, ...rows].join("\n");
565
+ };
566
+
567
+ const multiPrompt =
568
+ (host: boolean, x: Env): TerminalWidgetRenderer =>
569
+ (w, v) => {
570
+ const label = propText(w.props.label) || x.say("lu.input.choose");
571
+ const m = multiSelectValueIssues(v).length === 0 ? (v as MultiSelectValue) : undefined;
572
+ const byValue = new Map((m?.options ?? []).map((o) => [o.value, o]));
573
+ const chosen = (m?.selected ?? []).map((s) => byValue.get(s)).filter((o) => o !== undefined);
574
+ const text = (o: { label: string; hint?: string }) =>
575
+ `${oneLineChatText(o.label, 60)}${o.hint === undefined ? "" : ` (${oneLineChatText(o.hint, 40)})`}`;
576
+ const rows =
577
+ chosen.length === 0
578
+ ? [`${hang(host)} ${propText(w.props.emptyLabel) || x.say("lu.input.none")}`]
579
+ : chosen.map(
580
+ (o, i) => `${hang(host)} ${i + 1}) ${text(o)} ${x.say("lu.terminal.current")}`
581
+ );
582
+ const rest = (m?.options.length ?? 0) - chosen.length;
583
+ if (rest > 0) rows.push(`${hang(host)} + ${rest} ${x.say("lu.terminal.moreOptions")}`);
584
+ if (m?.note !== undefined) rows.push(`${hang(host)} ${oneLineChatText(m.note, 200)}`);
585
+ const head = `> ${label} ${x.say("lu.terminal.multiSelectInput")}`;
586
+ return [host ? head : `${title(w)}: ${head}`, ...rows].join("\n");
587
+ };
588
+
589
+ /** The framework kinds' renderers. A NEW registry each call — never mutate a shared one. */
590
+ export function createLoomUiTerminalRegistry(
591
+ opts: TerminalRegistryOptions = {}
592
+ ): RendererRegistry<TerminalWidgetRenderer> {
593
+ const host = opts.chrome === "host";
594
+ const x = envOf(opts.i18n);
595
+ return new RendererRegistry<TerminalWidgetRenderer>()
596
+ .register("TimeSeries", timeSeries(host, x))
597
+ .register("BarChart", barChart(host, x))
598
+ .register("Kpi", kpi(host, x))
599
+ .register("Table", table(host, x))
600
+ .register("StatusCard", statusCard(host, x))
601
+ .register("Note", note(host, x))
602
+ .register("Prose", prose(host, x))
603
+ .register("Diagram", diagram(host, x))
604
+ .register("A2UIView", a2uiView(host, x))
605
+ .register("SandboxView", sandboxView(host, x))
606
+ .register("Image", mediaLine(host, x, "Image", opts.media))
607
+ .register("Audio", mediaLine(host, x, "Audio", opts.media))
608
+ .register("Video", mediaLine(host, x, "Video", opts.media))
609
+ .register("File", mediaLine(host, x, "File", opts.media))
610
+ .register("MediaInput", mediaInput(host, x))
611
+ .register("Chat", chatTranscript(host, x))
612
+ .register("TextInput", textPrompt(host, x))
613
+ .register("FormInput", formPrompt(host, x))
614
+ .register("SelectInput", selectPrompt(host, x))
615
+ .register("MultiSelectInput", multiPrompt(host, x));
616
+ }
617
+
618
+ export const loomUiTerminalRegistry: RendererRegistry<TerminalWidgetRenderer> =
619
+ createLoomUiTerminalRegistry();
620
+
621
+ /**
622
+ * loom's eight built-ins plus the framework's kinds. A NEW registry each call — never mutate a shared one.
623
+ * With `chrome: "host"` the framework kinds print no title; loom's own eight built-ins are loom's
624
+ * renderers and keep theirs (the framework does not rewrite them).
625
+ */
626
+ export function terminalRegistry(
627
+ opts: TerminalRegistryOptions = {}
628
+ ): RendererRegistry<TerminalWidgetRenderer> {
629
+ return defaultTerminalRegistry.merge(createLoomUiTerminalRegistry(opts));
630
+ }