@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +369 -2
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/inert.js +13 -0
- package/dist/esm/inert.js.map +1 -0
- package/dist/esm/interactive.js +677 -0
- package/dist/esm/interactive.js.map +1 -0
- package/dist/esm/registry.js +428 -0
- package/dist/esm/registry.js.map +1 -0
- package/dist/esm/status.js +64 -0
- package/dist/esm/status.js.map +1 -0
- package/dist/esm/watch.js +17 -0
- package/dist/esm/watch.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/inert.d.ts +12 -0
- package/dist/inert.d.ts.map +1 -0
- package/dist/inert.js +17 -0
- package/dist/inert.js.map +1 -0
- package/dist/interactive.d.ts +142 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +685 -0
- package/dist/interactive.js.map +1 -0
- package/dist/registry.d.ts +55 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +434 -0
- package/dist/registry.js.map +1 -0
- package/dist/status.d.ts +49 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +69 -0
- package/dist/status.js.map +1 -0
- package/dist/watch.d.ts +9 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +20 -0
- package/dist/watch.js.map +1 -0
- package/package.json +60 -3
- package/src/index.ts +25 -0
- package/src/inert.ts +14 -0
- package/src/interactive.ts +868 -0
- package/src/registry.ts +630 -0
- package/src/status.ts +103 -0
- package/src/watch.ts +22 -0
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|