@sigrun/tui 0.4.1 → 0.5.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 (114) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/dist/actions.d.ts +119 -0
  3. package/dist/actions.js +12 -0
  4. package/dist/api.d.ts +209 -0
  5. package/dist/api.js +282 -0
  6. package/dist/blocks/braille.d.ts +41 -0
  7. package/dist/blocks/braille.js +131 -0
  8. package/dist/blocks/chart.d.ts +20 -0
  9. package/dist/blocks/chart.js +397 -0
  10. package/dist/blocks/chat-transport.d.ts +44 -0
  11. package/dist/blocks/chat-transport.js +116 -0
  12. package/dist/blocks/collection/calendar.d.ts +28 -0
  13. package/dist/blocks/collection/calendar.js +232 -0
  14. package/dist/blocks/collection/card.d.ts +36 -0
  15. package/dist/blocks/collection/card.js +128 -0
  16. package/dist/blocks/collection/collection.d.ts +9 -0
  17. package/dist/blocks/collection/collection.js +26 -0
  18. package/dist/blocks/collection/grid.d.ts +18 -0
  19. package/dist/blocks/collection/grid.js +72 -0
  20. package/dist/blocks/collection/lanes.d.ts +32 -0
  21. package/dist/blocks/collection/lanes.js +320 -0
  22. package/dist/blocks/collection/list.d.ts +30 -0
  23. package/dist/blocks/collection/list.js +273 -0
  24. package/dist/blocks/collection/move.d.ts +67 -0
  25. package/dist/blocks/collection/move.js +96 -0
  26. package/dist/blocks/collection/shared.d.ts +87 -0
  27. package/dist/blocks/collection/shared.js +177 -0
  28. package/dist/blocks/collection/table.d.ts +26 -0
  29. package/dist/blocks/collection/table.js +221 -0
  30. package/dist/blocks/content.d.ts +48 -0
  31. package/dist/blocks/content.js +253 -0
  32. package/dist/blocks/context.d.ts +117 -0
  33. package/dist/blocks/context.js +65 -0
  34. package/dist/blocks/diff.d.ts +21 -0
  35. package/dist/blocks/diff.js +25 -0
  36. package/dist/blocks/ext-transport.d.ts +70 -0
  37. package/dist/blocks/ext-transport.js +165 -0
  38. package/dist/blocks/form/choice.d.ts +54 -0
  39. package/dist/blocks/form/choice.js +175 -0
  40. package/dist/blocks/form/fields.d.ts +56 -0
  41. package/dist/blocks/form/fields.js +136 -0
  42. package/dist/blocks/form/file.d.ts +61 -0
  43. package/dist/blocks/form/file.js +121 -0
  44. package/dist/blocks/form/form.d.ts +66 -0
  45. package/dist/blocks/form/form.js +764 -0
  46. package/dist/blocks/form/list.d.ts +37 -0
  47. package/dist/blocks/form/list.js +70 -0
  48. package/dist/blocks/form/values.d.ts +20 -0
  49. package/dist/blocks/form/values.js +106 -0
  50. package/dist/blocks/icons.d.ts +14 -0
  51. package/dist/blocks/icons.js +74 -0
  52. package/dist/blocks/image.d.ts +39 -0
  53. package/dist/blocks/image.js +82 -0
  54. package/dist/blocks/interaction-transport.d.ts +42 -0
  55. package/dist/blocks/interaction-transport.js +79 -0
  56. package/dist/blocks/layout.d.ts +31 -0
  57. package/dist/blocks/layout.js +155 -0
  58. package/dist/blocks/log.d.ts +24 -0
  59. package/dist/blocks/log.js +111 -0
  60. package/dist/blocks/meter.d.ts +16 -0
  61. package/dist/blocks/meter.js +50 -0
  62. package/dist/blocks/page.d.ts +54 -0
  63. package/dist/blocks/page.js +360 -0
  64. package/dist/blocks/render.d.ts +31 -0
  65. package/dist/blocks/render.js +163 -0
  66. package/dist/blocks/surface-view.d.ts +41 -0
  67. package/dist/blocks/surface-view.js +137 -0
  68. package/dist/blocks/surface.d.ts +80 -0
  69. package/dist/blocks/surface.js +506 -0
  70. package/dist/blocks/tones.d.ts +23 -0
  71. package/dist/blocks/tones.js +63 -0
  72. package/dist/blocks/treeview.d.ts +22 -0
  73. package/dist/blocks/treeview.js +121 -0
  74. package/dist/blocks/width.d.ts +32 -0
  75. package/dist/blocks/width.js +70 -0
  76. package/dist/clipboard.d.ts +20 -0
  77. package/dist/clipboard.js +19 -0
  78. package/dist/editor-launch.d.ts +34 -0
  79. package/dist/editor-launch.js +70 -0
  80. package/dist/keymaps/emacs.d.ts +18 -0
  81. package/dist/keymaps/emacs.js +172 -0
  82. package/dist/keymaps/standard.d.ts +17 -0
  83. package/dist/keymaps/standard.js +175 -0
  84. package/dist/keymaps/types.d.ts +43 -0
  85. package/dist/keymaps/types.js +28 -0
  86. package/dist/keymaps/vim.d.ts +48 -0
  87. package/dist/keymaps/vim.js +291 -0
  88. package/dist/launch.d.ts +159 -0
  89. package/dist/launch.js +478 -0
  90. package/dist/sse.d.ts +112 -0
  91. package/dist/sse.js +418 -0
  92. package/dist/theme.d.ts +51 -0
  93. package/dist/theme.js +119 -0
  94. package/dist/ui/ambient.d.ts +78 -0
  95. package/dist/ui/ambient.js +264 -0
  96. package/dist/ui/app.d.ts +319 -0
  97. package/dist/ui/app.js +1539 -0
  98. package/dist/ui/composer.d.ts +66 -0
  99. package/dist/ui/composer.js +133 -0
  100. package/dist/ui/conversation-pane.d.ts +143 -0
  101. package/dist/ui/conversation-pane.js +615 -0
  102. package/dist/ui/help.d.ts +15 -0
  103. package/dist/ui/help.js +80 -0
  104. package/dist/ui/interactions.d.ts +91 -0
  105. package/dist/ui/interactions.js +477 -0
  106. package/dist/ui/picker.d.ts +26 -0
  107. package/dist/ui/picker.js +130 -0
  108. package/dist/ui/settings.d.ts +68 -0
  109. package/dist/ui/settings.js +254 -0
  110. package/dist/ui/sidebar.d.ts +99 -0
  111. package/dist/ui/sidebar.js +269 -0
  112. package/dist/ui/status-bar.d.ts +77 -0
  113. package/dist/ui/status-bar.js +146 -0
  114. package/package.json +4 -3
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The `page` shell (UI-blocks U2 Task 5, spec §5.1/§12.3 — the TUI port
3
+ * of U1's `Page.tsx`): a header line (title, subtitle), sidebar, main
4
+ * and detail regions — the channel draws them, the extension never
5
+ * positions them.
6
+ *
7
+ * - `empty` replaces MAIN ONLY when main's first collection binds to a
8
+ * missing path, `null` or `[]`; the sidebar and detail keep rendering.
9
+ * - At ≥ 100 columns the sidebar is a focusable pane beside main
10
+ * (Tab cycles sidebar → main); below 100 it is a toggled overlay
11
+ * (Tab toggles it; Esc closes it).
12
+ * - `ctrl+s` opens the header switcher (a `choice` field): Up/Down
13
+ * pick, Enter dispatches a `navigate` action with `{<name>: id}`.
14
+ * - `.` opens the actions menu: Up/Down pick, Enter dispatches the
15
+ * action, Esc closes. (Access `read` hides edit/answer actions.)
16
+ * - The detail region is a pushed view: Enter on main pushes it, Esc
17
+ * pops back. At ≥ 100 columns it renders beside main once pushed.
18
+ * - Esc with nothing open resolves at THIS layer (Task 4's host note:
19
+ * the app-level Esc handling for a surface with no pick-up and no
20
+ * open menu is the page's "unfocus" — the host routes it; the page
21
+ * reports `hasOverlay` so the host knows when Esc is already spoken
22
+ * for).
23
+ */
24
+ import { resolvePointer } from "@sigrun/shared/blocks";
25
+ import { bindText } from "./context.js";
26
+ import { iconGlyph } from "./icons.js";
27
+ import { renderBlock } from "./render.js";
28
+ import { pad, truncate, wrapPlain } from "./width.js";
29
+ /** The sidebar-pane minimum (spec §5.1/brief: ≥ 100 columns). */
30
+ export const SIDEBAR_PANE_MIN = 100;
31
+ /** The sidebar's pane width at the side-by-side layout. */
32
+ export const SIDEBAR_CELLS = 30;
33
+ /** The first `collection` block in a slot list, depth-first (mirrors
34
+ * U1's `firstCollection`). */
35
+ export function firstCollection(blocks) {
36
+ for (const block of blocks) {
37
+ if (block.type === "collection")
38
+ return block;
39
+ if (block.type === "stack") {
40
+ const found = firstCollection(block.children);
41
+ if (found !== undefined)
42
+ return found;
43
+ }
44
+ if (block.type === "section") {
45
+ const found = firstCollection(block.children.filter((child) => child.type === "collection" ||
46
+ child.type === "stack" ||
47
+ child.type === "columns" ||
48
+ child.type === "grid" ||
49
+ child.type === "section"));
50
+ if (found !== undefined)
51
+ return found;
52
+ }
53
+ if (block.type === "columns" || block.type === "grid") {
54
+ for (const child of block.children) {
55
+ const found = firstCollection([child.block]);
56
+ if (found !== undefined)
57
+ return found;
58
+ }
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+ /** Whether `empty` replaces main (spec §5.1): the first collection's
64
+ * `items` binding resolves missing, null or an empty array. */
65
+ export function isMainEmpty(block, data) {
66
+ if (block.empty === undefined)
67
+ return false;
68
+ const collection = firstCollection(block.main);
69
+ if (collection === undefined)
70
+ return false;
71
+ const itemsProp = collection.items;
72
+ // Literal rows (the ambient shape, U6 Task 3) resolve directly; the
73
+ // "$" check discriminates the binding (Array.isArray cannot narrow
74
+ // a readonly-array union).
75
+ if (typeof itemsProp !== "object" || !("$" in itemsProp)) {
76
+ return itemsProp.length === 0;
77
+ }
78
+ const items = resolvePointer(data, itemsProp.$);
79
+ return !Array.isArray(items) || items.length === 0;
80
+ }
81
+ export class PageComponent {
82
+ #block;
83
+ #ctx;
84
+ /** The open overlay: the switcher or the actions menu. */
85
+ #menu = null;
86
+ /** The pushed detail view (spec §12.3: detail → pushed page, Esc back). */
87
+ #detailPushed = false;
88
+ /** The sidebar overlay (compact) / focused pane (side-by-side). */
89
+ #sidebarOpen = false;
90
+ #region = "main";
91
+ /** The rendered region components (input forwards to the focused one). */
92
+ #main;
93
+ #sidebar;
94
+ #detail;
95
+ constructor(block, ctx, options = {}) {
96
+ this.#block = block;
97
+ this.#ctx = ctx;
98
+ this.#main = block.main.map((child) => renderBlock(child, ctx, options));
99
+ this.#sidebar = (block.sidebar ?? []).map((child) => renderBlock(child, ctx, options));
100
+ this.#detail = (block.detail ?? []).map((child) => renderBlock(child, ctx, options));
101
+ }
102
+ get #hasSidebar() {
103
+ return this.#sidebar.length > 0;
104
+ }
105
+ get #hasDetail() {
106
+ return this.#detail.length > 0;
107
+ }
108
+ /** True while an overlay (menu, sidebar drawer, pushed detail) owns Esc. */
109
+ get hasOverlay() {
110
+ return this.#menu !== null || this.#detailPushed || this.#sidebarOpen;
111
+ }
112
+ /** The header line: title (+ subtitle), switcher/actions hints. */
113
+ #headerLines(width) {
114
+ const title = bindText(this.#block.title, this.#ctx);
115
+ const subtitle = bindText(this.#block.subtitle, this.#ctx);
116
+ const hints = [];
117
+ if (this.#block.switcher !== undefined)
118
+ hints.push("ctrl+s switch");
119
+ if ((this.#block.actions ?? []).length > 0)
120
+ hints.push(". actions");
121
+ const hint = hints.length > 0 ? this.#ctx.theme.dim(` (${hints.join(" · ")})`) : "";
122
+ const lines = [truncate(`${this.#ctx.theme.bold(title)}${hint}`, width)];
123
+ if (subtitle !== "")
124
+ lines.push(truncate(this.#ctx.theme.dim(subtitle), width));
125
+ return lines;
126
+ }
127
+ #emptyLines(width) {
128
+ const empty = this.#block.empty;
129
+ if (empty === undefined)
130
+ return [];
131
+ const icon = iconGlyph(empty.icon, !this.#ctx.unicode);
132
+ const lines = [`${icon} ${empty.title}`];
133
+ if (empty.hint !== undefined && empty.hint !== "") {
134
+ for (const line of wrapPlain(empty.hint, width))
135
+ lines.push(this.#ctx.theme.dim(line));
136
+ }
137
+ return lines.map((line) => truncate(line, width));
138
+ }
139
+ #menuLines(width) {
140
+ const menu = this.#menu;
141
+ if (menu === null)
142
+ return [];
143
+ if (menu.kind === "switcher") {
144
+ const field = this.#block.switcher;
145
+ if (field === undefined)
146
+ return [];
147
+ const options = this.#switcherOptions(field);
148
+ return [
149
+ truncate(this.#ctx.theme.bold("Switch"), width),
150
+ ...options.map((option, index) => {
151
+ const cursor = index === menu.cursor ? "> " : " ";
152
+ return truncate(`${cursor}${bindText(option.label, this.#ctx) || option.id}`, width);
153
+ }),
154
+ truncate(this.#ctx.theme.dim("Enter selects · Esc closes"), width),
155
+ ];
156
+ }
157
+ const actions = this.#visibleActions();
158
+ return [
159
+ truncate(this.#ctx.theme.bold("Actions"), width),
160
+ ...actions.map((button, index) => {
161
+ const cursor = index === menu.cursor ? "> " : " ";
162
+ const icon = button.icon === undefined ? "" : `${iconGlyph(button.icon, !this.#ctx.unicode)} `;
163
+ return truncate(`${cursor}${icon}${bindText(button.label, this.#ctx) || button.id}`, width);
164
+ }),
165
+ truncate(this.#ctx.theme.dim("Enter runs · Esc closes"), width),
166
+ ];
167
+ }
168
+ #switcherOptions(field) {
169
+ const raw = field.options;
170
+ if (Array.isArray(raw))
171
+ return raw;
172
+ if (typeof raw === "object" && raw !== null && typeof raw.$ === "string") {
173
+ const resolved = resolvePointer(this.#ctx.data, raw.$);
174
+ if (Array.isArray(resolved))
175
+ return resolved;
176
+ }
177
+ return [];
178
+ }
179
+ /** The header actions the principal may run (access read hides
180
+ * edit/answer actions; navigate/open/copy stay — §6.5's read rule). */
181
+ #visibleActions() {
182
+ if (this.#ctx.access === "write")
183
+ return this.#block.actions ?? [];
184
+ return (this.#block.actions ?? []).filter((button) => button.action.type === "navigate" ||
185
+ button.action.type === "open" ||
186
+ button.action.type === "copy");
187
+ }
188
+ render(width) {
189
+ const lines = this.#headerLines(width);
190
+ if (this.#menu !== null) {
191
+ return [...lines, ...this.#menuLines(width)];
192
+ }
193
+ if (this.#detailPushed && this.#hasDetail) {
194
+ lines.push(truncate(this.#ctx.theme.dim("Esc back"), width));
195
+ for (const component of this.#detail) {
196
+ lines.push(...component.render(width));
197
+ }
198
+ return lines;
199
+ }
200
+ const empty = isMainEmpty(this.#block, this.#ctx.data);
201
+ const mainLines = empty
202
+ ? this.#emptyLines(width)
203
+ : this.#main.flatMap((component) => component.render(this.#mainWidth(width)));
204
+ const sideBySide = width >= SIDEBAR_PANE_MIN && this.#hasSidebar;
205
+ if (!sideBySide) {
206
+ // Compact/regular below the pane minimum: the sidebar is a
207
+ // toggled overlay (rendered above main while open).
208
+ if (this.#sidebarOpen && this.#hasSidebar) {
209
+ lines.push(truncate(this.#ctx.theme.dim("Esc closes the sidebar"), width));
210
+ for (const component of this.#sidebar) {
211
+ lines.push(...component.render(width));
212
+ }
213
+ return lines;
214
+ }
215
+ return [...lines, ...mainLines];
216
+ }
217
+ // The side-by-side layout: sidebar pane, gutter, main.
218
+ const sidebarLines = this.#sidebar.flatMap((component) => component.render(SIDEBAR_CELLS));
219
+ const height = Math.max(sidebarLines.length, mainLines.length);
220
+ for (let row = 0; row < height; row++) {
221
+ const left = pad(sidebarLines[row] ?? "", SIDEBAR_CELLS);
222
+ const right = mainLines[row] ?? "";
223
+ lines.push(truncate(`${left} ${right}`, width));
224
+ }
225
+ return lines;
226
+ }
227
+ #mainWidth(width) {
228
+ return width >= SIDEBAR_PANE_MIN && this.#hasSidebar
229
+ ? Math.max(1, width - SIDEBAR_CELLS - 1)
230
+ : width;
231
+ }
232
+ handleInput(data) {
233
+ // An open menu owns the keys.
234
+ if (this.#menu !== null) {
235
+ if (data === "\x1b") {
236
+ this.#menu = null;
237
+ return;
238
+ }
239
+ if (this.#menu.kind === "switcher") {
240
+ const field = this.#block.switcher;
241
+ if (field === undefined)
242
+ return;
243
+ const options = this.#switcherOptions(field);
244
+ if (data === "\x1b[A") {
245
+ this.#menu.cursor = Math.max(0, this.#menu.cursor - 1);
246
+ return;
247
+ }
248
+ if (data === "\x1b[B") {
249
+ this.#menu.cursor = Math.min(options.length - 1, this.#menu.cursor + 1);
250
+ return;
251
+ }
252
+ if (data === "\r") {
253
+ const option = options[this.#menu.cursor];
254
+ if (option !== undefined) {
255
+ // The switcher navigates (spec §5.1): params carry the id.
256
+ this.#ctx.dispatch({ type: "navigate", params: { [field.name]: option.id } }, { [field.name]: option.id });
257
+ }
258
+ this.#menu = null;
259
+ return;
260
+ }
261
+ return;
262
+ }
263
+ const actions = this.#visibleActions();
264
+ if (data === "\x1b[A") {
265
+ this.#menu.cursor = Math.max(0, this.#menu.cursor - 1);
266
+ return;
267
+ }
268
+ if (data === "\x1b[B") {
269
+ this.#menu.cursor = Math.min(actions.length - 1, this.#menu.cursor + 1);
270
+ return;
271
+ }
272
+ if (data === "\r") {
273
+ const button = actions[this.#menu.cursor];
274
+ if (button !== undefined)
275
+ this.#ctx.dispatch(button.action, {});
276
+ this.#menu = null;
277
+ return;
278
+ }
279
+ return;
280
+ }
281
+ // The pushed detail owns Esc (back).
282
+ if (this.#detailPushed) {
283
+ if (data === "\x1b") {
284
+ this.#detailPushed = false;
285
+ return;
286
+ }
287
+ for (const component of this.#detail)
288
+ component.handleInput?.(data);
289
+ return;
290
+ }
291
+ // The compact sidebar overlay owns Esc (close).
292
+ if (this.#sidebarOpen) {
293
+ if (data === "\x1b") {
294
+ this.#sidebarOpen = false;
295
+ return;
296
+ }
297
+ for (const component of this.#sidebar)
298
+ component.handleInput?.(data);
299
+ return;
300
+ }
301
+ if (data === "\x13") {
302
+ // ctrl+s opens the switcher.
303
+ if (this.#block.switcher !== undefined) {
304
+ this.#menu = { kind: "switcher", cursor: 0 };
305
+ }
306
+ return;
307
+ }
308
+ if (data === ".") {
309
+ if (this.#visibleActions().length > 0)
310
+ this.#menu = { kind: "actions", cursor: 0 };
311
+ return;
312
+ }
313
+ if (data === "\t") {
314
+ // Tab cycles the sidebar (a focused pane at ≥ 100 columns, a
315
+ // toggled overlay below).
316
+ if (this.#hasSidebar) {
317
+ if (this.#sideBySideKnown === true) {
318
+ this.#region = this.#region === "sidebar" ? "main" : "sidebar";
319
+ }
320
+ else {
321
+ this.#sidebarOpen = !this.#sidebarOpen;
322
+ }
323
+ }
324
+ return;
325
+ }
326
+ if (data === "\r" && this.#hasDetail && !this.#detailPushed) {
327
+ this.#detailPushed = true;
328
+ return;
329
+ }
330
+ // Forward to the focused region.
331
+ const target = this.#region === "sidebar" && this.#hasSidebar ? this.#sidebar : this.#main;
332
+ for (const component of target)
333
+ component.handleInput?.(data);
334
+ }
335
+ /** Whether the last render was the side-by-side layout (Tab's
336
+ * pane-vs-overlay decision — set by the host when it knows; the
337
+ * render path computes it from the width). */
338
+ #sideBySideKnown;
339
+ /** The host tells the page the current pane width's layout class
340
+ * (render computes it too; this setter keeps Tab consistent when
341
+ * input arrives between renders). */
342
+ setSideBySide(sideBySide) {
343
+ this.#sideBySideKnown = sideBySide;
344
+ }
345
+ invalidate() {
346
+ for (const component of [...this.#main, ...this.#sidebar, ...this.#detail]) {
347
+ component.invalidate();
348
+ }
349
+ }
350
+ /** Test introspection: the pushed detail / overlay state. */
351
+ get detailPushed() {
352
+ return this.#detailPushed;
353
+ }
354
+ get sidebarOpen() {
355
+ return this.#sidebarOpen;
356
+ }
357
+ get menuKind() {
358
+ return this.#menu?.kind ?? null;
359
+ }
360
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The renderer's front door (UI-blocks U2 Task 1): one pi-tui
3
+ * component per block, dispatched on `block.type`. Layout blocks come
4
+ * from `layout.ts`, the simple content blocks from `content.ts`; later
5
+ * tasks slot their blocks into the same dispatch.
6
+ *
7
+ * - `visibleAt` (spec §12.2) gates here so every block inherits it:
8
+ * `atLeast:regular` renders nothing in a compact pane. An unknown
9
+ * condition fails visible, matching §4.4's lean (a newer spec's
10
+ * blocks stay on screen).
11
+ * - A type this client can't render yet (spec §4.4) shows the block's
12
+ * `fallback` text plus "Not supported in this client" — never
13
+ * dropped silently.
14
+ */
15
+ import type { Component } from "@earendil-works/pi-tui";
16
+ import type { Block } from "@sigrun/shared/blocks";
17
+ import { type BlockCtx } from "./context.js";
18
+ import type { ImageOptions } from "./image.js";
19
+ export { widthClassOf } from "./width.js";
20
+ export type { WidthClass } from "./width.js";
21
+ export type { BlockCtx, ViewState } from "./context.js";
22
+ export type { ImageOptions } from "./image.js";
23
+ /** The fallback notice (spec §4.4's exact words). */
24
+ export declare const NOT_SUPPORTED = "Not supported in this client";
25
+ /** The seams `renderBlock` threads to the blocks that need them. */
26
+ export type RenderOptions = ImageOptions & {
27
+ /** Whether the form sits in an interaction placement (§9.1 stepper). */
28
+ interaction?: boolean;
29
+ };
30
+ /** Render any block as a pi-tui component (the renderer's dispatch). */
31
+ export declare function renderBlock(block: Block, ctx: BlockCtx, options?: RenderOptions): Component;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The renderer's front door (UI-blocks U2 Task 1): one pi-tui
3
+ * component per block, dispatched on `block.type`. Layout blocks come
4
+ * from `layout.ts`, the simple content blocks from `content.ts`; later
5
+ * tasks slot their blocks into the same dispatch.
6
+ *
7
+ * - `visibleAt` (spec §12.2) gates here so every block inherits it:
8
+ * `atLeast:regular` renders nothing in a compact pane. An unknown
9
+ * condition fails visible, matching §4.4's lean (a newer spec's
10
+ * blocks stay on screen).
11
+ * - A type this client can't render yet (spec §4.4) shows the block's
12
+ * `fallback` text plus "Not supported in this client" — never
13
+ * dropped silently.
14
+ */
15
+ import { hiddenAtCompact } from "./context.js";
16
+ import { Lines, renderColumns, renderGrid, renderSection, renderStack } from "./layout.js";
17
+ import { renderContent } from "./content.js";
18
+ import { renderChart } from "./chart.js";
19
+ import { renderMeter } from "./meter.js";
20
+ import { LogComponent } from "./log.js";
21
+ import { TreeComponent } from "./treeview.js";
22
+ import { ImageComponent } from "./image.js";
23
+ import { renderCollection } from "./collection/collection.js";
24
+ import { FormComponent } from "./form/form.js";
25
+ import { PageComponent } from "./page.js";
26
+ import { wrapPlain } from "./width.js";
27
+ // The renderer's public surface re-exports its seam types and the width
28
+ // classes so callers have ONE import site (`render.js`).
29
+ export { widthClassOf } from "./width.js";
30
+ /** The fallback notice (spec §4.4's exact words). */
31
+ export const NOT_SUPPORTED = "Not supported in this client";
32
+ /** A block this client can't render: its fallback text plus the notice. */
33
+ function unsupported(block, ctx) {
34
+ return new Lines((width) => {
35
+ const lines = [];
36
+ const fallback = typeof block.fallback === "string" ? block.fallback : "";
37
+ if (fallback !== "")
38
+ lines.push(...wrapPlain(fallback, width));
39
+ lines.push(ctx.theme.dim(NOT_SUPPORTED));
40
+ return lines;
41
+ });
42
+ }
43
+ /**
44
+ * `visibleAt` as a width-aware wrapper: the same component answers
45
+ * every pane width, so a resize re-evaluates the gate. Input handling
46
+ * (button picks, the confirm gate) forwards to the inner component.
47
+ */
48
+ class WidthGated {
49
+ #block;
50
+ #inner;
51
+ constructor(block, inner) {
52
+ this.#block = block;
53
+ this.#inner = inner;
54
+ }
55
+ render(width) {
56
+ if (hiddenAtCompact(this.#block, width))
57
+ return [];
58
+ return this.#inner.render(width);
59
+ }
60
+ handleInput(data) {
61
+ // Input forwards to the inner component. Width cannot gate it here
62
+ // (a component learns widths only while rendering); the host only
63
+ // routes keys to components it shows, which is where hiding lands.
64
+ this.#inner.handleInput?.(data);
65
+ }
66
+ invalidate() {
67
+ this.#inner.invalidate();
68
+ }
69
+ /** Dispose forwards too (a form's pending live debounce must not
70
+ * outlive its surface host). */
71
+ dispose() {
72
+ this.#inner.dispose?.();
73
+ }
74
+ /** Form introspection forwards (tests and hosts read the wrapped
75
+ * form's values/focus — the wrapper is transparent). */
76
+ get values() {
77
+ return this.#inner instanceof FormComponent ? this.#inner.values : undefined;
78
+ }
79
+ /** The form's field read (the interaction prompter's digit
80
+ * accelerator reads the working set before toggling). */
81
+ fieldValue(name) {
82
+ return this.#inner instanceof FormComponent
83
+ ? this.#inner.fieldValue(name)
84
+ : undefined;
85
+ }
86
+ get focusIndex() {
87
+ return this.#inner instanceof FormComponent ? this.#inner.focusIndex : undefined;
88
+ }
89
+ /** Focus-by-name forward (the settings view's search-resolve seam). */
90
+ focusName(name) {
91
+ return this.#inner instanceof FormComponent ? this.#inner.focusName(name) : false;
92
+ }
93
+ get step() {
94
+ return this.#inner instanceof FormComponent ? this.#inner.step : undefined;
95
+ }
96
+ get submittedOnce() {
97
+ return this.#inner instanceof FormComponent ? this.#inner.submittedOnce : undefined;
98
+ }
99
+ /** The interaction prompter's digit accelerator writes the pick into
100
+ * the wrapped form (the wrapper is transparent). */
101
+ setFieldValue(name, value) {
102
+ if (this.#inner instanceof FormComponent) {
103
+ this.#inner.setFieldValue(name, value);
104
+ }
105
+ }
106
+ /** Page introspection forwards (the wrapper is transparent for the
107
+ * page shell's overlay/detail state too). */
108
+ get hasOverlay() {
109
+ return this.#inner instanceof PageComponent ? this.#inner.hasOverlay : undefined;
110
+ }
111
+ get detailPushed() {
112
+ return this.#inner instanceof PageComponent ? this.#inner.detailPushed : undefined;
113
+ }
114
+ get sidebarOpen() {
115
+ return this.#inner instanceof PageComponent ? this.#inner.sidebarOpen : undefined;
116
+ }
117
+ get menuKind() {
118
+ return this.#inner instanceof PageComponent ? this.#inner.menuKind : undefined;
119
+ }
120
+ setSideBySide(sideBySide) {
121
+ if (this.#inner instanceof PageComponent)
122
+ this.#inner.setSideBySide(sideBySide);
123
+ }
124
+ }
125
+ /** The type dispatch; unknown types answer `undefined` from content. */
126
+ function dispatch(block, ctx, options) {
127
+ switch (block.type) {
128
+ case "stack":
129
+ return renderStack(block, ctx);
130
+ case "columns":
131
+ return renderColumns(block, ctx);
132
+ case "grid":
133
+ return renderGrid(block, ctx);
134
+ case "section":
135
+ return renderSection(block, ctx);
136
+ case "chart":
137
+ return renderChart(block, ctx);
138
+ case "meter":
139
+ return renderMeter(block, ctx);
140
+ case "log":
141
+ return new LogComponent(block, ctx);
142
+ case "tree":
143
+ return new TreeComponent(block, ctx);
144
+ case "image":
145
+ return new ImageComponent(block, ctx, options);
146
+ case "collection": {
147
+ const component = renderCollection(block, ctx);
148
+ return component ?? unsupported(block, ctx);
149
+ }
150
+ case "form":
151
+ return new FormComponent(block, ctx, {
152
+ interaction: options.interaction === true,
153
+ });
154
+ case "page":
155
+ return new PageComponent(block, ctx, options);
156
+ default:
157
+ return renderContent(block, ctx) ?? unsupported(block, ctx);
158
+ }
159
+ }
160
+ /** Render any block as a pi-tui component (the renderer's dispatch). */
161
+ export function renderBlock(block, ctx, options = {}) {
162
+ return new WidthGated(block, dispatch(block, ctx, options));
163
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SurfaceView (UI-blocks U2 Task 5, the port of U1's `SurfaceView.tsx`):
3
+ * the component host for a `SurfaceState` — loading, not-found, an
4
+ * error line with `r` to retry, ready. Ready builds the render context
5
+ * (the confirmed-plus-overlay data, the `/view/` branch, server field
6
+ * errors, dispatch and access) and renders the tree's root block.
7
+ *
8
+ * `handleInput` forwards to the rendered root component while ready;
9
+ * while error is showing, `r` retries the load (spec §12.3's fallback
10
+ * posture: an error never strands the surface).
11
+ */
12
+ import type { Component } from "@earendil-works/pi-tui";
13
+ import type { TuiTheme } from "../theme.js";
14
+ import { type RenderOptions } from "./render.js";
15
+ import type { SurfaceState } from "./surface.js";
16
+ /** The state lines (U1's wording, terminal-cased). */
17
+ export declare const SURFACE_LOADING = "loading\u2026";
18
+ export declare const SURFACE_NOT_FOUND = "surface not found";
19
+ export declare const SURFACE_ERROR = "something went wrong \u2014 r to retry";
20
+ export declare class SurfaceView implements Component {
21
+ #private;
22
+ constructor(surface: SurfaceState, opts: {
23
+ theme: TuiTheme;
24
+ locale?: string;
25
+ unicode: boolean;
26
+ options?: RenderOptions;
27
+ });
28
+ render(width: number): string[];
29
+ /**
30
+ * Focus the field with this name in the rendered form (the settings
31
+ * view's search-resolve seam, U4 Task 4): forces the component to
32
+ * build (the render gate) and forwards to the form's focus-by-name.
33
+ * Returns false when the surface is not a form or has no such field.
34
+ */
35
+ focusField(name: string): boolean;
36
+ handleInput(data: string): void;
37
+ invalidate(): void;
38
+ /** The wrapped component (test introspection AND the interaction
39
+ * prompter's pre-paint key routing: forms expose state). */
40
+ get inner(): Component | undefined;
41
+ }