gentle-shell-bridge 0.3.0 → 0.3.1

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 (75) hide show
  1. package/LICENSE +202 -0
  2. package/docs/contract.md +1 -1
  3. package/package.json +13 -1
  4. package/src/api.ts +1 -1
  5. package/.codegraph/.gitignore +0 -5
  6. package/PRD.md +0 -144
  7. package/examples/bash-card.ts +0 -1694
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +0 -30
  10. package/odd/tasks/plugin-keymap.md +0 -32
  11. package/odd/tasks/plugin-surface-contract.md +0 -55
  12. package/test/api.test.ts +0 -395
  13. package/test/apply-on-move.test.ts +0 -477
  14. package/test/arm.test.ts +0 -220
  15. package/test/bridge-ready.test.ts +0 -309
  16. package/test/bus.test.ts +0 -165
  17. package/test/button-rows.test.ts +0 -409
  18. package/test/capture.test.ts +0 -356
  19. package/test/card-style-rows.test.ts +0 -510
  20. package/test/card-style-transform.test.ts +0 -379
  21. package/test/card-style-wiring.test.ts +0 -258
  22. package/test/card-styles-e2e.test.ts +0 -491
  23. package/test/card-styles.test.ts +0 -324
  24. package/test/card-transcript-path.test.ts +0 -178
  25. package/test/config.test.ts +0 -93
  26. package/test/e2e.test.ts +0 -443
  27. package/test/example-bash-identity.test.ts +0 -372
  28. package/test/example-bash-matrix.test.ts +0 -856
  29. package/test/example-designs.test.ts +0 -734
  30. package/test/example-neon-transform.test.ts +0 -283
  31. package/test/example-plain-style.test.ts +0 -1268
  32. package/test/extension.test.ts +0 -160
  33. package/test/fake-events.ts +0 -58
  34. package/test/fake-host.ts +0 -211
  35. package/test/fake-sidebar.ts +0 -516
  36. package/test/fake-transcript.ts +0 -486
  37. package/test/frame-telemetry.test.ts +0 -417
  38. package/test/hook.test.ts +0 -196
  39. package/test/manifest.test.ts +0 -105
  40. package/test/new-category.test.ts +0 -979
  41. package/test/notices.test.ts +0 -328
  42. package/test/plugin-integration-lines.test.ts +0 -979
  43. package/test/plugin-surface.test.ts +0 -152
  44. package/test/presence.test.ts +0 -459
  45. package/test/rail-surfaces.test.ts +0 -457
  46. package/test/rail-width.test.ts +0 -423
  47. package/test/regression.test.ts +0 -483
  48. package/test/repaint-api.test.ts +0 -200
  49. package/test/reserved-categories.test.ts +0 -361
  50. package/test/row-labels.test.ts +0 -284
  51. package/test/row-names.test.ts +0 -473
  52. package/test/row-origin.test.ts +0 -395
  53. package/test/section-interaction.test.ts +0 -364
  54. package/test/section-panel.test.ts +0 -403
  55. package/test/sections-rows.test.ts +0 -291
  56. package/test/sidebar-backend.test.ts +0 -471
  57. package/test/sidebar-e2e.test.ts +0 -331
  58. package/test/sidebar-hover.test.ts +0 -276
  59. package/test/sidebar-independent.test.ts +0 -349
  60. package/test/sidebar-render.test.ts +0 -281
  61. package/test/sidebar-sections.test.ts +0 -245
  62. package/test/status-card-styles.test.ts +0 -201
  63. package/test/status-section-frame.test.ts +0 -181
  64. package/test/status-sections.test.ts +0 -243
  65. package/test/status.test.ts +0 -209
  66. package/test/store.test.ts +0 -192
  67. package/test/surface-repaint-cost.test.ts +0 -520
  68. package/test/tool-design-chain.test.ts +0 -483
  69. package/test/tool-design-cost.test.ts +0 -473
  70. package/test/tool-design-rows.test.ts +0 -359
  71. package/test/tool-designs.test.ts +0 -745
  72. package/test/transform-pipeline.test.ts +0 -465
  73. package/test/transforms-bus.test.ts +0 -233
  74. package/test/verifier-width-regressions.test.ts +0 -164
  75. package/tsconfig.json +0 -18
@@ -1,423 +0,0 @@
1
- /**
2
- * Width discipline on the host's rail (N17–N19), measured the way the host measures it.
3
- *
4
- * ## What decides the width of the main column
5
- *
6
- * The rail is the SECOND entry of the host's fullscreen `hstack`, with a FIXED basis:
7
- *
8
- * ```ts
9
- * { component: scroll, basis: RAIL_WIDTH, grow: 0, shrink: 0, minSize: RAIL_WIDTH }
10
- * ```
11
- * (`lib/shell-sidebar-layout.ts:493-496`; `RAIL_WIDTH = 50`, `GAP = 3` at `:9,:12`), so a part's
12
- * rows can never widen the rail column: pi-tui allocates a numeric `basis` without measuring the
13
- * child (`pi-tui/dist/layout.js:124`, `allocateStackSizes` in `dist/components/stack.js`). What a
14
- * part CAN change is whether the rail is painted at all:
15
- *
16
- * ```ts
17
- * const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
18
- * ```
19
- * (`lib/shell-sidebar-layout.ts:410`). When a single rail row is wider than the content width the
20
- * host drops the whole rail and the main column gets every column back — the layout's widths DID
21
- * change, from the part's output alone.
22
- *
23
- * ## How the host measures a row
24
- *
25
- * `visibleWidth` (`pi-tui/dist/utils.js:212-268`, pi-tui 1.5.x) strips complete CSI/OSC/APC
26
- * sequences, expands a TAB to THREE columns (`:229-231`), segments the rest into grapheme clusters
27
- * and charges each one the way `graphemeWidth` charges it (`:143-207`): a lone printable ASCII or
28
- * TAB by its own rule, a cluster of terminal-spacing marks by its code point count, zero for a
29
- * cluster of ignorables/controls/marks, two for an RGI emoji (after the host's own `couldBeEmoji`
30
- * prefilter, so a bare `©`, `☺` or `▶` is NOT an emoji and stays one column), two for a
31
- * regional-indicator pair, otherwise the base code point's East Asian `W|F` width plus every
32
- * terminal-spacing mark, mark-following visible code point (an Indic consonant after a virama),
33
- * halfwidth/fullwidth form and Thai/Lao AM vowel the cluster carries. This package's own
34
- * `visibleColumns` counts a tab as one column and every code point separately, so a row this
35
- * package believes is 48 columns wide can measure 51 to the host. The port below is the host's
36
- * model, kept here independently of `src/` — no production width helper is imported — so the
37
- * assertions do not test the implementation against itself; a self-check test pins the values
38
- * measured from the installed `visibleWidth`.
39
- *
40
- * ## The rail path, as the host paints it
41
- *
42
- * On the tinted panel surface a part's row becomes `" " + row + " "` (`:414`), and the width a part
43
- * is handed is `contentWidth - RAIL_PADDING * 2` (`:355`): 48 columns at `RAIL_WIDTH` 50. On a
44
- * transparent rail the host re-frames the rows itself through the same `< contentWidth` budget
45
- * (`sectionCardRows` → `cardLine`, `:36-47`), so the budget is the same number.
46
- */
47
-
48
- import assert from "node:assert/strict";
49
- import { mkdtempSync, rmSync } from "node:fs";
50
- import { tmpdir } from "node:os";
51
- import { join } from "node:path";
52
- import test from "node:test";
53
-
54
- import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
55
- import { resetSectionPanelForTests, setSectionTheme } from "../src/section-panel.ts";
56
- import { buildSectionContribution, installSectionRail, resetSectionFailuresForTests } from "../src/sidebar-render.ts";
57
- import { installSidebarBackend, resetSidebarStateForTests } from "../src/sidebar.ts";
58
- import { collectStatus, formatStatus } from "../src/status.ts";
59
- import { resetStoreStateForTests } from "../src/store.ts";
60
- import type { SectionContext, SidebarSection } from "../src/types.ts";
61
- import { resetSidebar } from "./fake-sidebar.ts";
62
-
63
- // ---------------------------------------------------------------------------------------------
64
- // The host's numbers, quoted from `lib/shell-sidebar-layout.ts`
65
- // ---------------------------------------------------------------------------------------------
66
-
67
- /** The rail column's basis (`:9`). */
68
- const RAIL_WIDTH = 50;
69
- /** One column of rail padding on each side of a panel row (`:10`). */
70
- const RAIL_PADDING = 1;
71
- /** The gap between the conversation and the rail (`:12`). */
72
- const GAP = 3;
73
- /** What the rail takes from the editor column (`:15`). */
74
- const RAIL_COLUMNS = RAIL_WIDTH + GAP;
75
- const TERMINAL_COLUMNS = 200;
76
-
77
- /** The width the host hands a part on the tinted panel surface (`:355`). */
78
- const GIVEN = RAIL_WIDTH - RAIL_PADDING * 2;
79
-
80
- /** The main column the host allocates when the rail paints: no basis of ours is measured. */
81
- const MAIN_WITH_RAIL = TERMINAL_COLUMNS - RAIL_COLUMNS;
82
- /** The main column when the rail does not paint at all. */
83
- const MAIN_WITHOUT_RAIL = TERMINAL_COLUMNS;
84
-
85
- /**
86
- * One complete escape sequence's length at `index`, read as the host's `extractAnsiCode` reads it
87
- * (`pi-tui/dist/utils.js:265-320`): CSI up to its final byte, OSC/APC up to BEL or ST. Zero when
88
- * there is none, so the walk copies the character.
89
- */
90
- function escapeLengthAt(line: string, index: number): number {
91
- if (line[index] !== "\u001b") return 0;
92
- const kind = line[index + 1];
93
- if (kind === "[") {
94
- let end = index + 2;
95
- while (end < line.length && !/[mGKHJ]/u.test(line[end] ?? "")) end += 1;
96
- return end < line.length ? end + 1 - index : 0;
97
- }
98
- if (kind === "]" || kind === "_") {
99
- let end = index + 2;
100
- while (end < line.length) {
101
- if (line[end] === "\u0007") return end + 1 - index;
102
- if (line[end] === "\u001b" && line[end + 1] === "\\") return end + 2 - index;
103
- end += 1;
104
- }
105
- }
106
- return 0;
107
- }
108
-
109
- /** A cluster of nothing but ignorables, controls, marks or surrogates: no columns (`:34`). */
110
- const ZERO_WIDTH = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
111
- /** What a cluster may carry before its base (`leadingNonPrintingRegex`, `:35`). */
112
- const LEADING_NON_PRINTING =
113
- /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
114
- /** One character the host's trailing loop skips (`nonPrintingCharRegex`, `:36`). */
115
- const NON_PRINTING_CHAR =
116
- /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})$/v;
117
- /** One combining mark (`markCharRegex`, `:37`). */
118
- const MARK_CHAR = /^\p{Mark}$/v;
119
- /**
120
- * Marks terminals allocate cells for, with or without a base (`terminalSpacingMarkRegex`, `:38-40`):
121
- * spacing marks except a few zero-width exceptions, plus the scripts whose spacing marks legacy
122
- * wcwidth tables charge.
123
- */
124
- const TERMINAL_SPACING_MARK =
125
- /^(?:[\p{Spacing_Mark}--[\u1734\u302E\u302F]]|[\u065F\u0F7F\u102B\u102C\u1031\u1033-\u1035\u1038\u103A-\u103E])+$/v;
126
- /** The emoji set the host tests AFTER its prefilter (`rgiEmojiRegex`, `:41`). */
127
- const RGI_EMOJI = /^\p{RGI_Emoji}$/v;
128
-
129
- /** The host's cheap emoji prefilter (`couldBeEmoji`, `:16-26`). */
130
- function couldBeEmoji(segment: string): boolean {
131
- const codePoint = segment.codePointAt(0) ?? 0;
132
- return (
133
- (codePoint >= 0x1f000 && codePoint <= 0x1fbff) ||
134
- (codePoint >= 0x2300 && codePoint <= 0x23ff) ||
135
- (codePoint >= 0x2600 && codePoint <= 0x27bf) ||
136
- (codePoint >= 0x2b50 && codePoint <= 0x2b55) ||
137
- segment.includes("\ufe0f") ||
138
- segment.length > 2
139
- );
140
- }
141
-
142
- /**
143
- * The East Asian Wide/Fullwidth blocks: a base code point in one of them is two columns
144
- * (`get-east-asian-width`'s `eastAsianWidth`, which `graphemeWidth` calls at `:151`). These are the
145
- * blocks a rail row can carry — CJK, Kana, Hangul, the fullwidth forms, enclosed ideographs and the
146
- * supplementary ideographic planes; a wide EMOJI is charged by the RGI rule above instead.
147
- */
148
- const WIDE: ReadonlyArray<readonly [number, number]> = [
149
- [0x1100, 0x115f],
150
- [0x2329, 0x232a],
151
- [0x2e80, 0x303e],
152
- [0x3041, 0xa4cf],
153
- [0xa960, 0xa97f],
154
- [0xac00, 0xd7a3],
155
- [0xf900, 0xfaff],
156
- [0xfe10, 0xfe19],
157
- [0xfe30, 0xfe6f],
158
- [0xff01, 0xff60],
159
- [0xffe0, 0xffe6],
160
- [0x16fe0, 0x16fe4],
161
- [0x17000, 0x18d08],
162
- [0x1b000, 0x1b2ff],
163
- [0x1d300, 0x1d376],
164
- [0x1f200, 0x1f2ff],
165
- [0x20000, 0x3fffd],
166
- ];
167
-
168
- function isWide(codePoint: number): boolean {
169
- return WIDE.some(([start, end]) => codePoint >= start && codePoint <= end);
170
- }
171
-
172
- /**
173
- * pi-tui's `visibleWidth`: escapes dropped, a tab three columns, and each grapheme cluster charged
174
- * as the host charges it. Kept independent of `src/` on purpose.
175
- */
176
- function hostWidth(line: string): number {
177
- let clean = "";
178
- let index = 0;
179
- while (index < line.length) {
180
- const length = escapeLengthAt(line, index);
181
- if (length > 0) {
182
- index += length;
183
- continue;
184
- }
185
- clean += line[index];
186
- index += 1;
187
- }
188
- // A TAB is three columns (`:229-231`); the host substitutes before segmenting.
189
- clean = clean.replace(/\t/gu, " ");
190
- // Fast path, like the host's: printable ASCII is one column per byte.
191
- if (/^[\x20-\x7e]*$/u.test(clean)) return clean.length;
192
- let total = 0;
193
- for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(clean)) {
194
- total += clusterWidth(segment);
195
- }
196
- return total;
197
- }
198
-
199
- /** One grapheme cluster's columns, in the host's own order (`graphemeWidth`, `:143-207`). */
200
- function clusterWidth(segment: string): number {
201
- // The host's own fast path: a lone printable ASCII byte is one column, a lone TAB three.
202
- if (segment.length === 1) {
203
- const code = segment.charCodeAt(0);
204
- if (code >= 0x20 && code <= 0x7e) return 1;
205
- if (code === 0x09) return 3;
206
- }
207
- // Some marks occupy cells even without a base character.
208
- if (TERMINAL_SPACING_MARK.test(segment)) return [...segment].length;
209
- if (ZERO_WIDTH.test(segment)) return 0;
210
- if (couldBeEmoji(segment) && RGI_EMOJI.test(segment)) return 2;
211
-
212
- const base = segment.replace(LEADING_NON_PRINTING, "");
213
- const codePoint = base.codePointAt(0);
214
- if (codePoint === undefined) return 0;
215
- if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) return 2;
216
-
217
- let width = isWide(codePoint) ? 2 : 1;
218
- let followsMark = false;
219
- for (const char of [...base].slice(1)) {
220
- if (TERMINAL_SPACING_MARK.test(char)) {
221
- width += 1;
222
- followsMark = false;
223
- } else if (MARK_CHAR.test(char)) {
224
- followsMark = true;
225
- } else if (!NON_PRINTING_CHAR.test(char)) {
226
- const trailing = char.codePointAt(0) ?? 0;
227
- if (followsMark) width += isWide(trailing) ? 2 : 1;
228
- else if (trailing >= 0xff00 && trailing <= 0xffef) width += isWide(trailing) ? 2 : 1;
229
- else if (trailing === 0x0e33 || trailing === 0x0eb3) width += 1;
230
- followsMark = false;
231
- }
232
- }
233
- return width;
234
- }
235
-
236
- // ---------------------------------------------------------------------------------------------
237
- // The oracle itself, pinned to numbers measured from the installed `visibleWidth`
238
- // ---------------------------------------------------------------------------------------------
239
-
240
- test("the host-width oracle agrees with the values pi-tui's own visibleWidth produces", () => {
241
- const measured: Array<[string, number]> = [
242
- ["a", 1],
243
- ["©", 1],
244
- ["©️", 2],
245
- ["1️⃣", 2],
246
- ["▶", 1],
247
- ["🎵", 2],
248
- ["ガ", 2],
249
- ["क्‍ष", 2],
250
- ["नमस्ते", 4],
251
- ["ि", 1],
252
- ["👨‍👩‍👧‍👦", 2],
253
- ["🇯🇵", 2],
254
- ["e\u0301", 1],
255
- ["\u0301", 0],
256
- ["a\tb", 5],
257
- ["中文", 4],
258
- ["한국어", 6],
259
- ["A", 2],
260
- ["👨\u001b[31m‍👩‍👧‍👦AB", 4],
261
- ["\u001b]8;;http://example.com\u0007a\u001b]8;;\u0007", 1],
262
- ];
263
- for (const [row, columns] of measured) {
264
- assert.equal(hostWidth(row), columns, `${JSON.stringify(row)} measures ${columns} to the host`);
265
- }
266
- });
267
-
268
- /**
269
- * The width the layout gives the main column, given what a part returned: the host pads each row by
270
- * one column on both sides (`:414`) and keeps the rail only while every rail row fits the content
271
- * width (`:410`). Otherwise the rail is dropped and the conversation gets the full terminal.
272
- */
273
- function mainColumnFor(partLines: readonly string[]): number {
274
- const railLines = partLines.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING));
275
- const active = railLines.length > 0 && railLines.every((line) => hostWidth(line) <= RAIL_WIDTH);
276
- return active ? MAIN_WITH_RAIL : MAIN_WITHOUT_RAIL;
277
- }
278
-
279
- // ---------------------------------------------------------------------------------------------
280
- // Fixtures
281
- // ---------------------------------------------------------------------------------------------
282
-
283
- let configHome: string;
284
- let previousConfigHome: string | undefined;
285
-
286
- test.before(() => {
287
- configHome = mkdtempSync(join(tmpdir(), "gsb-rail-width-"));
288
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
289
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
290
- });
291
-
292
- test.after(() => {
293
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
294
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
295
- rmSync(configHome, { recursive: true, force: true });
296
- });
297
-
298
- test.beforeEach(() => {
299
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
300
- resetBusForTests();
301
- resetSectionsForTests();
302
- resetSidebar();
303
- resetSidebarStateForTests();
304
- resetSectionPanelForTests();
305
- resetSectionFailuresForTests();
306
- resetStoreStateForTests();
307
- setSectionTheme(undefined);
308
- installSectionRail();
309
- installSidebarBackend(() => ({ terminal: { columns: TERMINAL_COLUMNS, rows: 40 } }));
310
- });
311
-
312
- const ctx: SectionContext = { theme: undefined, configHome: "" };
313
-
314
- function contribute(section: SidebarSection, width = GIVEN): string[] {
315
- registerSection(section);
316
- return buildSectionContribution(width, ctx)?.lines ?? [];
317
- }
318
-
319
- /** The widest row, as the host reads it, with the row itself for the failure message. */
320
- function widest(lines: readonly string[]): { width: number; text: string } {
321
- return lines.reduce(
322
- (worst, line) => (hostWidth(line) > worst.width ? { width: hostWidth(line), text: line } : worst),
323
- { width: 0, text: "" },
324
- );
325
- }
326
-
327
- // ---------------------------------------------------------------------------------------------
328
- // N17/N19 — a contributed part never asks for more room than it was given
329
- // ---------------------------------------------------------------------------------------------
330
-
331
- test("a section's rows never exceed the width the host handed it, measured the host's way", () => {
332
- const rows: Array<[string, SidebarSection]> = [
333
- ["plain ASCII", {
334
- id: "width.ascii",
335
- title: "ASCII",
336
- lines: () => ["transform registered", "second row"],
337
- }],
338
- // `clipRow` measures with `String#length`, so a row of wide characters is left untouched while
339
- // the host measures twice as many columns.
340
- ["a wide-character body row", {
341
- id: "width.wide",
342
- title: "WIDE",
343
- lines: () => ["日本語のテキストをここに書いて、幅を超えさせる行です。もっと長くします。"],
344
- }],
345
- // A tab is ONE column to this package and THREE to the host (`utils.js:153-155`), so a padded
346
- // row measures two columns wider than the width it was fitted to.
347
- ["a tab in a structured row", {
348
- id: "width.tab",
349
- title: "TAB",
350
- lines: () => [{ glyph: "○", label: "rendering\tmode", meta: "default", tone: "muted" }],
351
- }],
352
- ["an emoji cluster", {
353
- id: "width.emoji",
354
- title: "EMOJI",
355
- lines: () => [{ glyph: "🎵", label: "now playing 👨‍👩‍👧‍👦", meta: "▶", tone: "accent" }],
356
- }],
357
- ];
358
-
359
- // Every case is measured before the first failure, so one RED run names each violation and its
360
- // numbers instead of only the first.
361
- const violations: string[] = [];
362
- for (const [label, section] of rows) {
363
- resetSectionsForTests();
364
- const lines = contribute(section);
365
- assert.ok(lines.length > 0, `${label}: the section contributed no rows`);
366
- const worst = widest(lines);
367
- if (worst.width > GIVEN) {
368
- violations.push(
369
- `${label}: the host measures ${worst.width} columns for a row fitted to ${GIVEN} (over by ${worst.width - GIVEN}): ${JSON.stringify(worst.text)}`,
370
- );
371
- }
372
- }
373
- assert.deepEqual(violations, [], violations.join("\n"));
374
- });
375
-
376
- test("a contributed section leaves the main column exactly where the host put it", () => {
377
- // The host's own footer part, as the host paints it: one column of padding on both sides (`:414`).
378
- const hostFooter = ["▾ STATUS 2", " model claude", " cost $0.12"];
379
- assert.equal(mainColumnFor(hostFooter), MAIN_WITH_RAIL);
380
-
381
- const sections: SidebarSection[] = [
382
- { id: "width.plain", title: "ASCII", lines: () => ["transform registered"] },
383
- { id: "width.cjk", title: "WIDE", lines: () => ["日本語のテキストをここに書いて、幅を超えさせる行です。もっと長くします。"] },
384
- { id: "width.tabbed", title: "TAB", lines: () => [{ glyph: "○", label: "rendering\tmode", meta: "default" }] },
385
- ];
386
-
387
- for (const section of sections) {
388
- resetSectionsForTests();
389
- const lines = contribute(section);
390
- const rail = [...hostFooter, ...lines];
391
- assert.equal(
392
- mainColumnFor(rail),
393
- MAIN_WITH_RAIL,
394
- `section ${section.id}: the main column moved to ${mainColumnFor(rail)} (rail columns ${RAIL_COLUMNS}, terminal ${TERMINAL_COLUMNS}); ` +
395
- `the widest contributed row is ${widest(lines).width} columns for a ${GIVEN}-column budget`,
396
- );
397
- }
398
- });
399
-
400
- // ---------------------------------------------------------------------------------------------
401
- // N20 — the report carries the last render's budget and what the bridge did with it
402
- // ---------------------------------------------------------------------------------------------
403
-
404
- test("/bridge-status reports the last render's given width and the widest row the bridge produced", () => {
405
- contribute({
406
- id: "width.report",
407
- title: "REPORT",
408
- // A row fitted to the budget, and one that has to give way to it.
409
- lines: () => [{ glyph: "○", label: "rendering", meta: "default" }],
410
- });
411
- const report = formatStatus(collectStatus());
412
- const line = report.find((entry) => entry.startsWith("section width:"));
413
- assert.equal(line, `section width: ${GIVEN} columns given, widest row ${GIVEN}`);
414
- });
415
-
416
- test("a session whose rail never rendered a section keeps its report unchanged", () => {
417
- registerSection({ id: "width.noframe", title: "NOFRAME", lines: () => ["never rendered"] });
418
- const report = formatStatus(collectStatus());
419
- assert.deepEqual(
420
- report.filter((entry) => entry.startsWith("section width:")),
421
- [],
422
- );
423
- });