@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
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
package/NOTICE ADDED
@@ -0,0 +1,8 @@
1
+ loom-ui
2
+ Copyright 2026 Sushil Chhetri
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this project except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
package/README.md CHANGED
@@ -1,3 +1,370 @@
1
- # Temporary Holding Version
1
+ # @loomwork/ui-renderer-terminal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ String renderers for every framework kind, merged over loom's own kind-aware terminal registry,
4
+ plus `renderExperience` and `watch` — the terminal rendering surface for the `Blueprint`
5
+ `@loomwork/ui-core` compiles and the projection state `@loomwork/ui-hydration` publishes. Where
6
+ `@loomwork/ui-renderer-lit` mounts DOM elements, this package renders one plain-text frame; status
7
+ (loading/empty/stale/error) comes entirely from loom's own Blueprint traversal — this package
8
+ contributes only the per-kind value check.
9
+
10
+ ## Install / import
11
+
12
+ ```jsonc
13
+ // package.json of a consumer checked out beside this repo (the packages are not published)
14
+ "@loomwork/ui-renderer-terminal": "link:../loom-ui/packages/renderer-terminal"
15
+ ```
16
+
17
+ ```ts
18
+ import { renderExperience, watch, terminalRegistry } from "@loomwork/ui-renderer-terminal";
19
+ ```
20
+
21
+ ## Key concepts
22
+
23
+ - **The status matrix is loom's.** Since Blueprint IR 2.0.0 each binding declares its status channel
24
+ (`status.<handle>`, beside `values.<handle>` in the same projection state), and loom's own
25
+ `renderBlueprintToString` applies the eight-row decision table (loading, ready, empty, stale,
26
+ error, …). This package contributes exactly one thing to it: what a bound VALUE must look like,
27
+ through the `check` seam loom leaves open — `kindValueCheck`.
28
+ - **One registry, merged over loom's.** `terminalRegistry()` returns loom's
29
+ `defaultTerminalRegistry` merged with this package's own renderers for the framework kinds
30
+ (`TimeSeries`, `BarChart`, `Kpi`, `Table`, `StatusCard`, `Note`, `Prose`, `Diagram`, `A2UIView`,
31
+ `SandboxView`, the media kinds `Image`/`Audio`/`Video`/`File`/`MediaInput` and the chat kinds `Chat`,
32
+ `TextInput`, `FormInput`, `SelectInput`, `MultiSelectInput`) — a fresh registry each call, since loom's default registries are shared singletons that must never be
33
+ mutated in place.
34
+ - **`renderExperience` reads one well-known projection.** A `HydrationStore` always publishes under
35
+ `HYDRATION_PROJECTION_ID`; `renderExperience` maps every OTHER projection id the Blueprint
36
+ declares to `undefined` state, so a binding under a different id renders as its honest loading row
37
+ rather than an error.
38
+ - **Numeric parity with the web.** Every framework-kind renderer here formats a number exactly as
39
+ `@loomwork/ui-renderer-lit`'s `measure()` would for the same value, format and unit — same rounding,
40
+ grouping, currency symbol and unit suffix — via `@loomwork/ui-core`'s `formatSemanticNumber` and
41
+ `effectiveFormat`, at one fixed locale (`en-US` by default) so the terminal output does not vary
42
+ with the host's own locale; a host opts into another with `i18n.locale` (see "Localising the
43
+ renderer").
44
+
45
+ ## API
46
+
47
+ - `renderExperience(blueprint, store: HydrationStore, opts?: RenderExperienceOptions): string` —
48
+ render one frame: loom's status matrix over `store.state()`, this package's registry and value
49
+ check by default. `opts.registry` overrides the registry.
50
+ - `watch(blueprint, store, onFrame: (frame: string) => void, opts?): () => void` — call `onFrame`
51
+ now and on every change to the store's projection (via `store.runtimeSubscribe`); returns an
52
+ unsubscribe function.
53
+ - `terminalRegistry(): RendererRegistry<TerminalWidgetRenderer>` — loom's built-in terminal registry
54
+ merged with `loomUiTerminalRegistry`. Call it fresh each time you need one. `terminalRegistry({
55
+ chrome: "host" })` (also on `renderExperience` and `createLoomUiTerminalRegistry`) is for an app
56
+ that draws its own title bar or frame: the framework kinds print no `Title:` prefix — ready, error
57
+ (`✗ msg`) or loading (`—`) — and a Kpi's caption trails the figure, and multi-line bodies (Table,
58
+ TimeSeries, BarChart) print with no 4-space hanging indent — that indent only sits rows under the
59
+ `Title:` lead, which host chrome does not have (so a narrow cell keeps its columns). Default
60
+ `"widget"` is unchanged. loom's own traversal rows (status lines) and its eight built-in kinds are
61
+ loom's output and keep their titles. Only a non-empty string is a title: `""` or a non-string
62
+ (`0`, `5`) is none — the kind stands in, a Note prints no title — exactly as in the lit renderer.
63
+ `renderExperience` throws if given both `chrome` and `registry` (chrome would be silently
64
+ ignored); use `terminalRegistry({ chrome })` for yours.
65
+ - `loomUiTerminalRegistry` — just the framework kinds' renderers, unmerged, for a caller building
66
+ its own composed registry.
67
+ - `sparkline(values: readonly number[]): string` — an eight-level Unicode sparkline (`▁▂▃▄▅▆▇█`)
68
+ over a series of numbers, used by the `TimeSeries` renderer and available standalone.
69
+ - `kindValueCheck: WidgetValueCheck` — the framework's per-kind value contract, plugged into loom's
70
+ `check` seam; exported so a caller assembling its own `renderBlueprintToString` call can reuse it.
71
+ `kindValueCheckFor(contracts)` is the same check that also validates a host's own kinds against the
72
+ catalogue's `valueContracts` (what `renderExperience` uses when given `contracts`).
73
+ - `RenderExperienceOptions` — `{ registry?, contracts?, chrome?, media?, i18n?, projectionState?,
74
+ projectionSubscribe? }` (it extends `TerminalRegistryOptions`: `chrome`, `media`, `i18n`).
75
+ `projectionState(id)` supplies the state of any NON-hydration projection the blueprint declares (a
76
+ node bound through core's `SemanticNode.projection`), e.g. `(id) => runtime.getState(id,
77
+ store.executionId)`; absent or throwing, its widgets render as loading.
78
+ `projectionSubscribe(onChange)` lets `watch` re-render when such a projection changes.
79
+
80
+ ## Examples
81
+
82
+ Rendering once, and re-rendering on every change (illustrative — not a compiling snippet):
83
+
84
+ ```ts
85
+ import { renderExperience, watch } from "@loomwork/ui-renderer-terminal";
86
+
87
+ // One frame, right now:
88
+ process.stdout.write(renderExperience(blueprint, store));
89
+
90
+ // Live: re-render on every hydration update, clearing the screen each time.
91
+ const stop = watch(blueprint, store, (frame) => process.stdout.write(`\x1Bc${frame}`));
92
+ // later: stop();
93
+ ```
94
+
95
+ Composing your own registry (illustrative):
96
+
97
+ ```ts
98
+ import { loomUiTerminalRegistry } from "@loomwork/ui-renderer-terminal";
99
+ import { defaultTerminalRegistry } from "@loomwork/plugin-renderer-terminal";
100
+
101
+ const myRegistry = defaultTerminalRegistry.merge(loomUiTerminalRegistry).merge(myOwnKinds);
102
+ ```
103
+
104
+ ## Notable behaviours and guarantees
105
+
106
+ - **Chat and inputs (U8).** `Chat` prints under its title as the SAME lines the web speaks
107
+ (`chatTranscriptLines`): `You: ...`, `Assistant (interrupted): ...`, `You (edit of turn 1): ...`,
108
+ a failure as its fixed sentence plus `(ref <id>)`, a restricted entry as `[restricted assistant
109
+ turn]`, media described without an address, then `(the assistant is replying...)` / `(conversation
110
+ closed)` / `... earlier messages are available`. Each turn is one bounded line of cleaned text: an
111
+ escape sequence, a bidi override or a newline in a turn cannot reach the screen or forge a
112
+ `Assistant:` line. `TextInput` / `FormInput` / `SelectInput` / `MultiSelectInput` print a prompt that says what they
113
+ ask (fields, numbered options, the current choice listed with `(current)`, and a count of the other options) and that the answer goes through an interactive surface (the
114
+ registry draws, it does not read a line): a terminal host that collects answers sends them through
115
+ the same `createWriteBack` as the web. The web renderer's `chatAccessories` (host content drawn under
116
+ an entry) has no terminal counterpart: the terminal prints the transcript only, and a host's
117
+ accessory content is not shown here.
118
+ - **Source references are footnotes.** A `Kpi`, `BarChart`, `TimeSeries`, `Table`, `StatusCard` or `Diagram` value
119
+ with `refs` prints one `[n] label` line per source under the widget's output (`[n]` alone without a label), the
120
+ label cut to one clean line like any untrusted text. The terminal has no clickable markers, so it does not emit
121
+ `REF_OPEN` and does not claim the conformance capability `references`.
122
+ - **Diagram prints core\'s shared outline.** `Diagram` prints its title, the value's own `title`, then the
123
+ SAME lines the web's hidden list holds (`diagramOutline`): a tree as an indented outline, a flow or
124
+ network as `A → B (label)` lines, a timeline as `date — label` lines in chronological order. Every label
125
+ is cut to one clean line before the outline joins them, so no control or bidi character, and no newline,
126
+ reaches the screen. A value that breaks a rule (over 200 nodes, a dangling edge, two tree roots, ...) is
127
+ the usual `✗` error line.
128
+ - **Media prints a line, never loads (U9).** `Image`/`Audio`/`Video`/`File` print the same one-line
129
+ description the web speaks (`describeMedia`: `image chart.png (image/png, 2 KiB)`), `[untrusted]`
130
+ unless `media.trusted(ref)` says the host vouches (never for `origin: "user"`), and either a
131
+ re-checked `media.link(ref)` (`checkMediaUrl`) or a NON-REVERSIBLE tag `ref#1a2b3c4d` (first 8 hex
132
+ of the SHA-256 of the address; never any part of the address itself), which is a capability and
133
+ terminal output is copied and logged. A declared size over `media.policy.maxBytes` prints "too
134
+ large to open" with no link. Name and alt are stripped of control and bidi characters. Untrusted
135
+ active types print "blocked (active content type)" with no link; a same-origin link is refused for
136
+ untrusted media. `MediaInput` prints that it is a web control. A restricted media node is a
137
+ `Note`, as for any kind.
138
+
139
+
140
+ - **A restricted node prints as a `Note`.** `compile({ viewer })` replaces a node the viewer may
141
+ not see with a static, unbound `Note`, printed as `Restricted: Restricted` (or `Restricted` under
142
+ `chrome: "host"`); it is bound to nothing, so a value the store happens to hold is never read.
143
+ Enforcement is `compile`'s and hydration's, not the renderer's.
144
+
145
+ - **A declared channel with no value yet prints loom's loading row** (`⋯ <title>`); an `empty`
146
+ status prints `○ <title>: <message>` — both are loom's own rows, not this package's.
147
+ - **A value that fails its kind's contract shows a visible error**, never a silent blank: every
148
+ framework-kind renderer here checks the bound value with `checkKindValue` (via `kindValueCheck`)
149
+ before drawing it.
150
+ - **Unbound widgets read props, not a channel.** `Note` is never bound: loom calls its renderer
151
+ with `value === undefined`, and its text comes from `props.text` — a compiled `LocalizedText`
152
+ always resolves to its fallback text at compile time, so the terminal never shows a message key.
153
+ - **Web-only kinds say so, honestly.** `A2UIView` and `SandboxView` cannot render their real
154
+ content in a terminal (an A2UI interpreter and sandboxed script execution are web surfaces this
155
+ CommonJS package does not import); their terminal renderers print
156
+ `[A2UI view — rendered on the web]` / `[sandboxed component — rendered on the web]` rather than
157
+ faking a render.
158
+ - **Never mutate a shared registry.** `terminalRegistry()` and `loom-ui`'s framework registry are
159
+ built fresh; never call `.register()` on loom's own `defaultTerminalRegistry` — it is a shared
160
+ singleton other code may depend on being unmodified.
161
+ - **Deterministic, bounded output.** A long series or table never spreads its full length into a
162
+ single-pass min/max (avoiding V8's argument-count ceiling); `Table` prints at most 20 rows before
163
+ an `"… N more rows"` line, and every numeric column is width-capped so one long cell cannot push
164
+ the rest of a row off-screen.
165
+
166
+ ## Localising the renderer
167
+
168
+ Every fixed word the terminal prints is a key in the catalogue `LU_MESSAGES` (and the typed key list
169
+ `LU_MESSAGE_KEYS`) exported by `@loomwork/ui-core`; English is the default. Pass a host override as
170
+ `i18n` to `terminalRegistry`, `createLoomUiTerminalRegistry` or `renderExperience`:
171
+
172
+ ```ts
173
+ import { renderExperience } from "@loomwork/ui-renderer-terminal";
174
+
175
+ renderExperience(blueprint, store, {
176
+ i18n: {
177
+ locale: "de-DE", // numbers and plural categories; default "en-US" (DEFAULT_TERMINAL_LOCALE)
178
+ messages: { "lu.terminal.chat.empty": "(keine Nachrichten)" },
179
+ translate: (key, english) => undefined, // optional; undefined keeps English
180
+ },
181
+ });
182
+ ```
183
+
184
+ - **The default locale stays `en-US`**, not the process's locale: terminal output is logged, diffed
185
+ and snapshotted, so the same value must print the same bytes on every machine. A host that wants
186
+ another opts in with `i18n.locale`.
187
+ - Lookup order is `messages`, then `translate(key, english)`, then English; a throwing `translate`
188
+ is English. `{name}` placeholders must be kept. A counted noun is `<base>.<category>`
189
+ (`lu.plural.row.one` / `.other`, `.few`, ...); the locale's category applies only to a translated
190
+ form.
191
+ - Not covered: bound data (titles, labels, cells, chat text), loom's own eight built-in kinds and
192
+ the `✗`/`○`/`~`/`⋯` status lines loom's traversal writes (loom's renderers), and developer
193
+ diagnostics.
194
+
195
+ The keys the terminal asks for (including the shared chat and media words from `@loomwork/ui-core`):
196
+
197
+ | Key | English default |
198
+ | --- | --- |
199
+ | `lu.plural.row.one` | `row` |
200
+ | `lu.plural.row.other` | `rows` |
201
+ | `lu.level.ok` | `ok` |
202
+ | `lu.level.warning` | `warning` |
203
+ | `lu.level.critical` | `critical` |
204
+ | `lu.terminal.table.moreRows` | `… {n} more {noun}` |
205
+ | `lu.media.kind.image` | `image` |
206
+ | `lu.media.kind.audio` | `audio` |
207
+ | `lu.media.kind.video` | `video` |
208
+ | `lu.media.kind.file` | `file` |
209
+ | `lu.chat.who.assistant` | `Assistant` |
210
+ | `lu.chat.who.notice` | `Notice` |
211
+ | `lu.chat.who.you` | `You` |
212
+ | `lu.chat.who.user` | `User` |
213
+ | `lu.prose.more` | `Show more` |
214
+ | `lu.prose.cut` | `(document cut short)` |
215
+ | `lu.prose.empty` | `(no text)` |
216
+ | `lu.chat.who.userNamed` | `User "{name}"` |
217
+ | `lu.approval.approve` | `Approve` |
218
+ | `lu.approval.reject` | `Reject` |
219
+ | `lu.chat.sending` | `Sending…` |
220
+ | `lu.chat.typeFirst` | `Type a message first.` |
221
+ | `lu.chat.dispatch.stale` | `The view changed. Review it and send again.` |
222
+ | `lu.chat.dispatch.unavailable` | `Sending is not available here.` |
223
+ | `lu.chat.dispatch.failed` | `Not sent. Try again.` |
224
+ | `lu.input.sent` | `Sent` |
225
+ | `lu.input.none` | `None` |
226
+ | `lu.input.error.tooLong` | `This is too long.` |
227
+ | `lu.chat.status.replying` | `replying` |
228
+ | `lu.chat.status.interrupted` | `interrupted` |
229
+ | `lu.chat.status.failed` | `failed` |
230
+ | `lu.chat.status.rejected` | `not accepted` |
231
+ | `lu.chat.status.editOf` | `edit of turn {turn}` |
232
+ | `lu.chat.line.restricted` | `[restricted {role} turn]` |
233
+ | `lu.chat.line.restrictedFrom` | `[restricted {role} turn from {by}]` |
234
+ | `lu.chat.line.ref` | `(ref {id})` |
235
+ | `lu.chat.line.tools` | `[tools: {tools}]` |
236
+ | `lu.chat.line.reasoning` | `[reasoning: {text}]` |
237
+ | `lu.chat.line.reasoningSummary` | `[reasoning summary: {text}]` |
238
+ | `lu.chat.line.reasoningRedacted` | `[reasoning redacted]` |
239
+ | `lu.chat.line.reasoningErased` | `[reasoning removed]` |
240
+ | `lu.chat.reason.model-error` | `The assistant ran into a problem.` |
241
+ | `lu.chat.reason.rate-limited` | `The assistant is busy right now. Try again in a moment.` |
242
+ | `lu.chat.reason.quota` | `The assistant's usage limit has been reached.` |
243
+ | `lu.chat.reason.tool-error` | `A tool the assistant used failed.` |
244
+ | `lu.chat.reason.denied` | `That was not permitted.` |
245
+ | `lu.chat.reason.turn-failed` | `The turn could not be completed.` |
246
+ | `lu.chat.reason.cancelled` | `The turn was cancelled.` |
247
+ | `lu.chat.reason.too-long` | `The message was too long.` |
248
+ | `lu.chat.reason.prompt-too-large` | `The message was too large to answer.` |
249
+ | `lu.chat.reason.malformed` | `The message was not understood.` |
250
+ | `lu.chat.reason.unauthenticated` | `The message could not be verified.` |
251
+ | `lu.chat.reason.replayed` | `The message was already received.` |
252
+ | `lu.chat.reason.stale` | `The message arrived too late.` |
253
+ | `lu.chat.reason.not-a-participant` | `You are not a participant in this conversation.` |
254
+ | `lu.chat.reason.edit-invalid` | `That edit could not be applied to the earlier message.` |
255
+ | `lu.chat.reason.generic` | `The message was not processed.` |
256
+ | `lu.input.message` | `Message` |
257
+ | `lu.input.form` | `Form` |
258
+ | `lu.input.choose` | `Choose` |
259
+ | `lu.terminal.a2ui` | `[A2UI view — rendered on the web]` |
260
+ | `lu.terminal.sandbox` | `[sandboxed component — rendered on the web]` |
261
+ | `lu.terminal.fileUpload` | `[file upload — available on the web]` |
262
+ | `lu.terminal.untrusted` | `[untrusted]` |
263
+ | `lu.terminal.media.tooLarge` | `too large to open` |
264
+ | `lu.terminal.media.blocked` | `blocked (active content type)` |
265
+ | `lu.terminal.chat.earlier` | `… earlier messages are available` |
266
+ | `lu.terminal.chat.empty` | `(no messages)` |
267
+ | `lu.terminal.chat.replying` | `(the assistant is replying…)` |
268
+ | `lu.terminal.chat.closed` | `(conversation closed)` |
269
+ | `lu.terminal.multiline` | ` (multi-line)` |
270
+ | `lu.terminal.textInput` | `[text input — answer on an interactive surface]` |
271
+ | `lu.terminal.formInput` | `[form — answer on an interactive surface]` |
272
+ | `lu.terminal.current` | `(current)` |
273
+ | `lu.terminal.selectInput` | `[choice — answer on an interactive surface]` |
274
+ | `lu.terminal.multiSelectInput` | `[choices — answer on an interactive surface]` |
275
+ | `lu.terminal.moreOptions` | `more options` |
276
+
277
+ ## Interactive terminal and incremental render
278
+
279
+ `renderExperience` stays a snapshot: a string in, a string out, byte for byte what it always was.
280
+ `interactiveTerminal(blueprint, { store, ... })` is the opt-in live session over the same renderer.
281
+ Its frame is that snapshot followed by a controls footer when the Blueprint has actionable widgets;
282
+ with none, the frame IS the snapshot.
283
+
284
+ ```ts
285
+ import { interactiveTerminal } from "@loomwork/ui-renderer-terminal";
286
+
287
+ const term = interactiveTerminal(blueprint, {
288
+ store, // the HydrationStore; changes repaint
289
+ input: process.stdin, // anything with on("data"); raw mode is set while live
290
+ output: process.stdout, // anything with write(); omit for a headless frame()
291
+ onAction: (a) => writeBack.handle(a), // WidgetAction + viewedVersion; may return an ActionOutcome
292
+ });
293
+ term.update(revisedBlueprint); // incremental: only the changed lines are rewritten
294
+ term.stop();
295
+ ```
296
+
297
+ - **Incremental render.** The first paint clears the screen and writes the frame; afterwards each
298
+ change diffs the new frame against the last (`diffFrames`) and writes only the changed lines as
299
+ cursor-addressed rewrites (`paintPatches`). `onPaint` reports `{ mode: "full" | "patch" | "none",
300
+ changedLines, totalLines }`. A change that leaves the frame as it was writes nothing.
301
+ - **Controls.** `ApprovalCard` (only while an approval is pending, like the web), `SelectInput`, `MultiSelectInput`
302
+ and `TextInput`. Tab / Shift-Tab (or the arrows) move focus. Approvals take `y` / `n` only: Enter is
303
+ never consent. A select opens a list (Enter), picks by digit or arrows + Enter, Escape backs out. The list starts on the first option: it does not preselect a bound current choice (the non-interactive prompt does mark it, `(current)`), and the terminal ignores a SelectInput's `size`.
304
+ `MultiSelectInput` opens a checklist (Enter): arrows move, Space ticks or unticks (never past `maxSelected`), Enter sends the whole set, Escape backs out. It starts with the bound choice ticked and shows 10 options around the cursor.
305
+ A text input starts on Enter, edits with Backspace, sends on Enter, Escape cancels.
306
+ - **Consent is deliberate.** `y` / `n` and every submit act only when they are the SOLE key of their
307
+ input chunk, on the frame they were pressed on: a key that changes mode or submits ends the chunk
308
+ (the rest is dropped), so a paste can never navigate to an approval and answer it. The session
309
+ turns on bracketed paste (`ESC[?2004h`, off again on `stop`) and a pasted span is TEXT only: it is
310
+ appended to an open draft (newlines kept), otherwise it is nothing. A consent key queued behind an
311
+ in-flight action is refused (`lu.chat.dispatch.stale`, "The view changed. Review it and send again.") if the blueprint or a control's binding status
312
+ moved since it was pressed, and `viewedVersion` is stamped from that frame, not from whatever is
313
+ current when the key finally runs. After an `APPROVE` / `REJECT` is dispatched for a pending
314
+ approval, further `y` / `n` for it are ignored until the frame moves (the host answered, or a new
315
+ version landed): the HOST must still dedupe by its own key, since another session can answer too.
316
+ `press(...keys)` treats each key as its own arrival; `send(chunk)` and real input are chunks.
317
+ - **Sole means sole.** Every decoded unit of a chunk counts toward "sole", including dropped escape
318
+ sequences (arrows, OSC) and non-printables: `ESC[C` + `y` is two units and cannot approve. Paste
319
+ mode always ends: at the end marker (even split across chunks), at Ctrl-C (paste discarded,
320
+ interrupt delivered), when the paste passes 1 Mi UTF-16 code units (discarded; the rest is
321
+ swallowed to the end marker, never typed), or after `pasteIdleMs` (default 2000, read from an
322
+ injectable `now`) without a byte. An idle-abandoned paste may still be arriving, so the decoder
323
+ then DRAINS: it discards input (nothing is typed) until the end marker or Ctrl-C (delivered as an
324
+ interrupt). A further silence does not end the drain (a slow link can stall twice); a bound plus
325
+ silence does: after 1 Mi UTF-16 code units more with no end marker AND `pasteIdleMs` of silence
326
+ the input is handed back (a still-flowing paste keeps draining), so a lost marker costs at most
327
+ that much typing, never all of it. A drain or an oversize paste that ends inside a chunk yields an
328
+ `ignored` key, so a `y` after it is not sole. During a drain Ctrl-C is delivered as an interrupt
329
+ (by default this stops the session).
330
+ - **Known limits, and `confirmConsent`.** A chunk boundary is all a terminal gives us. (1) On a
331
+ terminal WITHOUT bracketed paste, a one-character paste `y` into a focused approval is a one-key
332
+ chunk, taken as a keypress. (2) A host reading byte by byte (slow serial / ssh) makes every key
333
+ sole, so a multi-line non-bracketed paste could reach `\t`, `y`. For raw-byte-stream hosts set
334
+ `confirmConsent: true` (recommended there; default off): the first `y` / `n` only arms the
335
+ approval (the frame shows `y ×2`, or `labels.confirm`), and the same key alone in a LATER chunk on
336
+ the same frame acts; any other key disarms. (3) A paste whose end marker never arrives is drained
337
+ for 1 MiB, after which what follows is decoded as keys (the sole-key rule still blocks consent);
338
+ Ctrl-C leaves the drain at once.
339
+ - **Actions** are the shapes the web emits (`APPROVE` / `REJECT` with `payload.approved`, `SELECT`,
340
+ `SUBMIT_TEXT` with an idempotency `key`), stamped with `viewedVersion`. What was typed is kept
341
+ until the host answers `ok`; a refusal or a throw is a fixed sentence from `LU_MESSAGES`, never
342
+ the host's text. No `onAction` = "not available here"; nothing is pretended to be sent.
343
+ - **No TTY needed.** `input` / `output` are injected; `send("\t")` and `press({ name: "enter" })`
344
+ feed keys and resolve when the effect, including the action, has settled. `decodeKeys` is the pure
345
+ parser: unknown escape sequences are dropped whole, control characters and bidi overrides are
346
+ never typed text. Ctrl-C calls `onInterrupt` (default `stop`).
347
+ - `labels` overrides the footer's key legend and focus marker (symbols by default); every other word
348
+ uses the host's `i18n`. Form inputs are not driven here (use a surface with fields).
349
+
350
+ `renderExperience` and the interactive frame (controls footer included) strip core's one unsafe set
351
+ (`stripUnsafeChars`, the class `sanitizeChatText` removes: C0/C1 controls, ESC, bidi overrides and
352
+ isolates, zero-width and invisible marks, soft hyphen, TAG characters) plus tab, CR and U+2028/9
353
+ from the whole frame; only newline survives. A title, heading, cell, translated word or typed draft
354
+ can be model- or user-authored, and an ESC would otherwise reach the terminal as an escape sequence.
355
+ "Inert" means STRIPPED, not parsed: the printable bytes after a removed ESC (such as `]8;;http://…`)
356
+ may remain visible. They are harmless text. A typed `Key.char` must be exactly one printable code
357
+ point or it is ignored.
358
+
359
+ The renderer runs the shared conformance kit (`@loomwork/ui-renderer-conformance`) in
360
+ `src/conformance.test.ts`.
361
+
362
+ ## See also
363
+
364
+ - [../core/README.md](../core/README.md) — the framework kinds and number semantics this package
365
+ renders, and `checkKindValue`.
366
+ - [../hydration/README.md](../hydration/README.md) — the `HydrationStore` and projection state
367
+ `renderExperience`/`watch` read.
368
+ - [../renderer-lit/README.md](../renderer-lit/README.md) — the DOM counterpart surface, with the
369
+ same numeric formatting.
370
+ - [../../README.md](../../README.md) — root overview.
@@ -0,0 +1,6 @@
1
+ export { createLoomUiTerminalRegistry, loomUiTerminalRegistry, sparkline, terminalRegistry, } from "./registry";
2
+ export { DEFAULT_TERMINAL_LOCALE } from "./registry";
3
+ export { kindValueCheck, kindValueCheckFor, renderExperience } from "./status";
4
+ export { watch } from "./watch";
5
+ export { decodeKeys, diffFrames, interactiveTerminal, paintPatches } from "./interactive";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,EACtB,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAE/E,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,13 @@
1
+ import { stripUnsafeChars } from "@loomwork/ui-core/chat-ui";
2
+ /**
3
+ * Make text inert for a terminal: core's ONE unsafe set (`stripUnsafeChars` — the same class
4
+ * `sanitizeChatText` removes: C0/C1 controls incl. ESC and NUL, bidi overrides/isolates, zero-width
5
+ * and invisible marks, soft hyphen, TAG characters), plus tab, carriage return and the Unicode line
6
+ * separators. Newline is the only control kept: it is the frame's own line structure.
7
+ *
8
+ * It STRIPS; it does not parse. The bytes after a stripped ESC are ordinary printable text, so
9
+ * `ESC ]8;;http://x BEL` leaves a harmless visible `]8;;http://x`. Idempotent; printable text is
10
+ * never touched.
11
+ */
12
+ export const inert = (text) => stripUnsafeChars(text).replace(/[\t\r\u{2028}\u{2029}]/gu, "");
13
+ //# sourceMappingURL=inert.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inert.js","sourceRoot":"","sources":["../../src/inert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAE7D;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,IAAY,EAAU,EAAE,CAC5C,gBAAgB,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,0BAA0B,EAAE,EAAE,CAAC,CAAC"}