gentle-shell-bridge 0.3.0 → 0.3.2

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 (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -722
  3. package/docs/contract.md +92 -35
  4. package/package.json +13 -1
  5. package/src/api.ts +1 -1
  6. package/.codegraph/.gitignore +0 -5
  7. package/PRD.md +0 -144
  8. package/examples/bash-card.ts +0 -1694
  9. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  10. package/odd/tasks/palette-sections.md +0 -30
  11. package/odd/tasks/plugin-keymap.md +0 -32
  12. package/odd/tasks/plugin-surface-contract.md +0 -55
  13. package/test/api.test.ts +0 -395
  14. package/test/apply-on-move.test.ts +0 -477
  15. package/test/arm.test.ts +0 -220
  16. package/test/bridge-ready.test.ts +0 -309
  17. package/test/bus.test.ts +0 -165
  18. package/test/button-rows.test.ts +0 -409
  19. package/test/capture.test.ts +0 -356
  20. package/test/card-style-rows.test.ts +0 -510
  21. package/test/card-style-transform.test.ts +0 -379
  22. package/test/card-style-wiring.test.ts +0 -258
  23. package/test/card-styles-e2e.test.ts +0 -491
  24. package/test/card-styles.test.ts +0 -324
  25. package/test/card-transcript-path.test.ts +0 -178
  26. package/test/config.test.ts +0 -93
  27. package/test/e2e.test.ts +0 -443
  28. package/test/example-bash-identity.test.ts +0 -372
  29. package/test/example-bash-matrix.test.ts +0 -856
  30. package/test/example-designs.test.ts +0 -734
  31. package/test/example-neon-transform.test.ts +0 -283
  32. package/test/example-plain-style.test.ts +0 -1268
  33. package/test/extension.test.ts +0 -160
  34. package/test/fake-events.ts +0 -58
  35. package/test/fake-host.ts +0 -211
  36. package/test/fake-sidebar.ts +0 -516
  37. package/test/fake-transcript.ts +0 -486
  38. package/test/frame-telemetry.test.ts +0 -417
  39. package/test/hook.test.ts +0 -196
  40. package/test/manifest.test.ts +0 -105
  41. package/test/new-category.test.ts +0 -979
  42. package/test/notices.test.ts +0 -328
  43. package/test/plugin-integration-lines.test.ts +0 -979
  44. package/test/plugin-surface.test.ts +0 -152
  45. package/test/presence.test.ts +0 -459
  46. package/test/rail-surfaces.test.ts +0 -457
  47. package/test/rail-width.test.ts +0 -423
  48. package/test/regression.test.ts +0 -483
  49. package/test/repaint-api.test.ts +0 -200
  50. package/test/reserved-categories.test.ts +0 -361
  51. package/test/row-labels.test.ts +0 -284
  52. package/test/row-names.test.ts +0 -473
  53. package/test/row-origin.test.ts +0 -395
  54. package/test/section-interaction.test.ts +0 -364
  55. package/test/section-panel.test.ts +0 -403
  56. package/test/sections-rows.test.ts +0 -291
  57. package/test/sidebar-backend.test.ts +0 -471
  58. package/test/sidebar-e2e.test.ts +0 -331
  59. package/test/sidebar-hover.test.ts +0 -276
  60. package/test/sidebar-independent.test.ts +0 -349
  61. package/test/sidebar-render.test.ts +0 -281
  62. package/test/sidebar-sections.test.ts +0 -245
  63. package/test/status-card-styles.test.ts +0 -201
  64. package/test/status-section-frame.test.ts +0 -181
  65. package/test/status-sections.test.ts +0 -243
  66. package/test/status.test.ts +0 -209
  67. package/test/store.test.ts +0 -192
  68. package/test/surface-repaint-cost.test.ts +0 -520
  69. package/test/tool-design-chain.test.ts +0 -483
  70. package/test/tool-design-cost.test.ts +0 -473
  71. package/test/tool-design-rows.test.ts +0 -359
  72. package/test/tool-designs.test.ts +0 -745
  73. package/test/transform-pipeline.test.ts +0 -465
  74. package/test/transforms-bus.test.ts +0 -233
  75. package/test/verifier-width-regressions.test.ts +0 -164
  76. package/tsconfig.json +0 -18
@@ -1,1268 +0,0 @@
1
- /**
2
- * T29–T31 / S17: the example's `plain` card style, restored with the same id, and proven to compose
3
- * with everything it was asked to compose with (L8).
4
- *
5
- * The user's words, verbatim:
6
- *
7
- * > nose si se rompio o lo sacaste pero ya no sale en card style la opcion plain que agregaba el
8
- * > ejemplo, hay que recuperala y probar que se integre bien con todo (con card content y con card
9
- * > tool modificadas como bash y con sidebar nativa y expandidas/agragadas)
10
- *
11
- * The style was removed by the parent's own instruction in `8334667`, not by a defect (E7): the
12
- * original definition is `5c0c123:examples/bash-card.ts:344-415`, its coverage lived in
13
- * `5c0c123:test/example-transform.test.ts` and `5c0c123:test/example-late-load.test.ts`. This file
14
- * restores both, then adds the integration matrix the user named.
15
- *
16
- * What is REAL here and what is MODELLED:
17
- *
18
- * - real: the example registers through the published API; the bridge's own card-style transform
19
- * (`installCardStyleTransform` / `installRailCardStyleTransform` through the extension entry), its
20
- * sidebar backend wrapper and its section renderer (`buildSectionContribution`), its card-style
21
- * rows (the host's own `Card style` line, extended), its store, and the host's own `neon`/`float`
22
- * rows.
23
- * - modelled: the host's framing of a rail section into a `float` card. `hostSectionCard` below is
24
- * pinned against the host-produced fixtures `RAIL_HOST_CARD` / `RAIL_BRIDGE_CARD`: it reproduces
25
- * both byte for byte, so the bytes it draws for the example's own section follow the same shape.
26
- * - fixtures: every conversation card and every rail card is the host's or pi's own output; the
27
- * derivation is the comment block in `test/fake-transcript.ts` (conversation) and
28
- * `test/fake-sidebar.ts` (rail).
29
- *
30
- * The matrix is stated as a table in the test names themselves, including the cells that are NOT
31
- * reachable: card content `default` is pi's native card, not a float frame, so neither a host style
32
- * nor a registered one reaches it. Pass-through is pinned by array identity, never by "looks the
33
- * same", and `/bridge-status` has to say so too.
34
- */
35
-
36
- import assert from "node:assert/strict";
37
- import { mkdtempSync, rmSync } from "node:fs";
38
- import { tmpdir } from "node:os";
39
- import { join } from "node:path";
40
- import test from "node:test";
41
-
42
- import {
43
- BRIDGE_API_KEY,
44
- BRIDGE_READY_EVENT,
45
- announceBridgeReady,
46
- bridgeApi,
47
- publishBridgeApi,
48
- resetBridgeApiForTests,
49
- } from "../src/api.ts";
50
- import { armBridge } from "../src/arm.ts";
51
- import {
52
- resetBusForTests,
53
- resetCardStylesForTests,
54
- resetSectionsForTests,
55
- snapshotCardStyles,
56
- snapshotSections,
57
- } from "../src/bus.ts";
58
- import {
59
- installCardStyleRows,
60
- resetCardStyleRowStateForTests,
61
- } from "../src/card-style-rows.ts";
62
- import {
63
- getCardStyleLastOutcome,
64
- resetCardStyleTransformForTests,
65
- transformCardLines,
66
- } from "../src/card-style-transform.ts";
67
- import {
68
- disableCardStyleTransform,
69
- resetCardStyleWiringForTests,
70
- installCardStyleTransform,
71
- } from "../src/card-style-wiring.ts";
72
- import {
73
- getCardStyleSelection,
74
- resolveCardStyleSelection,
75
- setCardStyleSelection,
76
- } from "../src/card-styles.ts";
77
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
78
- import gentleShellBridge, { type BridgeExtensionApi } from "../src/extension.ts";
79
- import { resetHookStateForTests } from "../src/hook.ts";
80
- import { resetNoticesForTests } from "../src/notices.ts";
81
- import { resetPluginLinesForTests } from "../src/plugin-lines.ts";
82
- import { resetRepaintForTests } from "../src/repaint.ts";
83
- import { buildRows, resetRowBuildStateForTests } from "../src/rows.ts";
84
- import { setSectionTheme, resetSectionPanelForTests } from "../src/section-panel.ts";
85
- import { resetSectionPointerForTests } from "../src/section-pointer.ts";
86
- import {
87
- SIDEBAR_RAIL_KEY,
88
- disableSidebarBackend,
89
- resetSidebarStateForTests,
90
- } from "../src/sidebar.ts";
91
- import { buildSectionContribution, resetSectionFailuresForTests } from "../src/sidebar-render.ts";
92
- import { collectStatus, formatStatus } from "../src/status.ts";
93
- import { resetStoreStateForTests } from "../src/store.ts";
94
- import { resetToolDesignsForTests, setToolDesignSelection, snapshotToolDesigns } from "../src/tool-designs.ts";
95
- import { resetToolDesignRowStateForTests } from "../src/tool-design-rows.ts";
96
- import { resetToolDesignWiringForTests } from "../src/tool-design-wiring.ts";
97
- import type { CustomizeRow, InlineHelpers } from "../src/types.ts";
98
- import { setSectionCollapsed } from "../src/visibility.ts";
99
- import { createFakeEvents } from "./fake-events.ts";
100
- import { createFakeHost, type FakeHost } from "./fake-host.ts";
101
- import {
102
- RAIL_HOST_CARD,
103
- type Rail,
104
- createRailTui,
105
- fakeState,
106
- registerNativeFooter,
107
- resetSidebar,
108
- } from "./fake-sidebar.ts";
109
- import {
110
- BASH_DEFAULT_CALL,
111
- BASH_DEFAULT_RESULT,
112
- BASH_MINIMAL_FLOAT_CALL,
113
- BASH_MINIMAL_FLOAT_RESULT,
114
- HOST_READ_CARD,
115
- READ_CARD,
116
- createFakeTranscriptTui,
117
- stripAnsi,
118
- } from "./fake-transcript.ts";
119
-
120
- // ---------------------------------------------------------------------------------------------
121
- // Shared harness
122
- // ---------------------------------------------------------------------------------------------
123
-
124
- type SessionHandler = (event: unknown, ctx: { hasUI?: boolean; ui?: unknown }) => void;
125
-
126
- interface FakePi {
127
- on?: (event: string, handler: SessionHandler) => void;
128
- registerCommand?: () => void;
129
- registerToolRenderer?: (resolver: unknown) => void;
130
- getCommands?: () => Array<{ name: string }>;
131
- events?: unknown;
132
- }
133
-
134
- type ExampleInit = (pi: FakePi) => () => void;
135
-
136
- /** One fresh module instance per scenario: the example keeps nothing on `globalThis`. */
137
- const loadExample = async (tag: string): Promise<ExampleInit> => {
138
- const specifier = new URL(`../examples/bash-card.ts?${tag}`, import.meta.url).href;
139
- return ((await import(specifier)) as { default: ExampleInit }).default;
140
- };
141
-
142
- const hostSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
143
-
144
- const inlineHelpers: InlineHelpers = {
145
- input: async () => undefined,
146
- confirm: async () => true,
147
- capture: async () => undefined,
148
- };
149
-
150
- const recordingPi = (events?: unknown): { pi: FakePi; sessionHandlers: SessionHandler[] } => {
151
- const sessionHandlers: SessionHandler[] = [];
152
- const pi: FakePi = {
153
- on(_event, handler) {
154
- sessionHandlers.push(handler);
155
- },
156
- ...(events === undefined ? {} : { events }),
157
- };
158
- return { pi, sessionHandlers };
159
- };
160
-
161
- const notifySurface = (): {
162
- ui: { notify: (message: string, level?: string) => void };
163
- calls: Array<[string, string | undefined]>;
164
- } => {
165
- const calls: Array<[string, string | undefined]> = [];
166
- return { ui: { notify: (message, level) => calls.push([message, level]) }, calls };
167
- };
168
-
169
- const loadBridge = (events: ReturnType<typeof createFakeEvents>): void => {
170
- const pi: BridgeExtensionApi = { on() {}, registerCommand() {}, events };
171
- gentleShellBridge(pi);
172
- };
173
-
174
- /** How many `plain` styles and `CARD BASH` sections this load owns right now. */
175
- const surfaceCounts = (): number[] => [
176
- snapshotCardStyles().filter((style) => style.id === "bash-card.plain").length,
177
- snapshotSections().filter((section) => section.id === "bash-card.status").length,
178
- ];
179
-
180
- let configHome: string;
181
- let previousConfigHome: string | undefined;
182
-
183
- test.before(() => {
184
- configHome = mkdtempSync(join(tmpdir(), "gsb-example-plain-"));
185
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
186
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
187
- });
188
-
189
- test.after(() => {
190
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
191
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
192
- rmSync(configHome, { recursive: true, force: true });
193
- });
194
-
195
- test.beforeEach(() => {
196
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
197
- resetBridgeApiForTests();
198
- resetBusForTests();
199
- resetSectionsForTests();
200
- resetCardStylesForTests();
201
- resetCardStyleRowStateForTests();
202
- resetCardStyleTransformForTests();
203
- resetCardStyleWiringForTests();
204
- resetCaptureStateForTests();
205
- resetHookStateForTests();
206
- resetNoticesForTests();
207
- resetPluginLinesForTests();
208
- resetRepaintForTests();
209
- resetRowBuildStateForTests();
210
- resetSectionFailuresForTests();
211
- resetSectionPanelForTests();
212
- resetSectionPointerForTests();
213
- resetSidebar();
214
- resetSidebarStateForTests();
215
- resetStoreStateForTests();
216
- resetToolDesignsForTests();
217
- resetToolDesignRowStateForTests();
218
- resetToolDesignWiringForTests();
219
- disableCaptureBackend();
220
- disableSidebarBackend();
221
- disableCardStyleTransform();
222
- setCardStyleSelection("native");
223
- });
224
-
225
- test.afterEach(() => {
226
- disableCaptureBackend();
227
- disableSidebarBackend();
228
- disableCardStyleTransform();
229
- });
230
-
231
- // ---------------------------------------------------------------------------------------------
232
- // T29/T30 — the restored style, its registration and its coverage
233
- // ---------------------------------------------------------------------------------------------
234
-
235
- test("T29: the example registers `plain` (id bash-card.plain) on the Card style row, with its literal preview", async () => {
236
- // The host's own line, as gentle-shell builds it (`extensions/gentle-shell.ts:3398-3419`).
237
- type HostRow = CustomizeRow;
238
- let hostStyle = "float";
239
- const hostStyleRow = (value: "neon" | "float"): HostRow => ({
240
- category: "Cards",
241
- label: `Card style: ${value}`,
242
- action: () => {
243
- hostStyle = value;
244
- },
245
- choice: {
246
- setting: "Card style",
247
- value,
248
- current: () => hostStyle === value,
249
- },
250
- });
251
- const host = createFakeHost({
252
- rows: [
253
- { category: "Cards", label: "Card content: default", action: () => {} },
254
- hostStyleRow("neon"),
255
- hostStyleRow("float"),
256
- { category: "Layout", label: "Layout: builtin", action: () => {} },
257
- ],
258
- });
259
- armBridge(host.ui);
260
- installCardStyleRows();
261
-
262
- publishBridgeApi();
263
- const { pi, sessionHandlers } = recordingPi();
264
- (await loadExample("plain-row"))(pi);
265
- const { ui, calls } = notifySurface();
266
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
267
-
268
- assert.deepEqual(calls, [], "nothing to warn about: the bridge published the surface");
269
- assert.deepEqual(surfaceCounts(), [1, 1]);
270
-
271
- const [style] = snapshotCardStyles();
272
- assert.ok(style !== undefined);
273
- assert.equal(style.id, "bash-card.plain", "the SAME id, so a stored selection still resolves");
274
- assert.equal(style.label, "plain", "the value the user reads on the host's line");
275
- assert.deepEqual(
276
- style.preview?.(),
277
- {
278
- title: "Cards · plain",
279
- sample: ["▌ read ctrl+o", "▌ config.ts", "▌"].join("\n"),
280
- },
281
- "the literal preview restored from `5c0c123:examples/bash-card.ts:394-401`",
282
- );
283
-
284
- host.buildMenu();
285
-
286
- assert.deepEqual(
287
- host.toggleLines().filter((line) => line.startsWith("Card style ")),
288
- ["Card style ○ neon ● float ○ plain"],
289
- "one line: the host's own values first, then the example's `plain`",
290
- );
291
- const plain = host
292
- .liveRows()
293
- .find((row) => row.choice?.setting === "Card style" && row.choice?.value === "plain");
294
- assert.ok(plain !== undefined, "the value is a row on the host's own line, not a second line");
295
- assert.equal(plain.choice?.current(), false, "native rendering by default");
296
- });
297
-
298
- test("T30: selecting `plain` from its row persists the selection and repaints the card already on screen", async () => {
299
- publishBridgeApi();
300
- const { pi, sessionHandlers } = recordingPi();
301
- (await loadExample("plain-select"))(pi);
302
- const { ui } = notifySurface();
303
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
304
-
305
- const tui = createFakeTranscriptTui({ lines: [...READ_CARD] });
306
- assert.deepEqual(installCardStyleTransform(tui), {
307
- installed: true,
308
- outcome: "wrapped",
309
- component: "chat",
310
- });
311
- installCardStyleRows();
312
-
313
- const row = buildRows({ theme: undefined, configHome }).rows.find(
314
- (candidate) => candidate.choice?.value === "plain",
315
- );
316
- assert.ok(row?.action !== undefined, "the value carries the action that selects it");
317
- await row.action(inlineHelpers);
318
-
319
- assert.equal(getCardStyleSelection(), "bash-card.plain", "the value is persisted, not just drawn");
320
- const selected = resolveCardStyleSelection();
321
- assert.equal(selected.kind, "style", "the stored id resolves against the registered style");
322
-
323
- const repainted = tui.chat.render(40);
324
- assert.equal(repainted.length, 3, "the host's five float rows become top + body + bottom");
325
- assert.equal(stripAnsi(repainted[0] ?? "").startsWith("▌ read"), true);
326
- assert.equal(stripAnsi(repainted[0] ?? "").endsWith("ctrl+o"), true, "the host's hint is kept");
327
- assert.deepEqual(
328
- repainted.slice(1).map((line) => stripAnsi(line)),
329
- ["▌ config.ts", "▌"],
330
- "the card already on screen is repainted: a `read` card, because the style is for tool cards",
331
- );
332
- assert.deepEqual(getCardStyleLastOutcome("conversation"), {
333
- kind: "transformed",
334
- styleId: "bash-card.plain",
335
- cards: 1,
336
- });
337
- });
338
-
339
- test("T30: an older bridge without registerCardStyle keeps the design, the section and the demo category, and says `card style absent`", async () => {
340
- const published = bridgeApi() ?? publishBridgeApi();
341
- // First-wins: a mixed process can hand back a v1 object that predates `registerCardStyle`.
342
- hostSlot()[BRIDGE_API_KEY] = {
343
- schema: published.schema,
344
- version: published.version,
345
- VERSION: published.VERSION,
346
- registerToolDesign: published.registerToolDesign,
347
- registerRows: published.registerRows,
348
- registerSection: published.registerSection,
349
- createStore: published.createStore,
350
- };
351
-
352
- const { pi, sessionHandlers } = recordingPi();
353
- const dispose = (await loadExample("plain-old-bridge"))(pi);
354
- const { ui, calls } = notifySurface();
355
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
356
-
357
- assert.deepEqual(calls, [], "a surface the bridge never promised is never warned about");
358
- assert.deepEqual(snapshotToolDesigns("bash").map((design) => design.id), ["bash-card.bash-heading"]);
359
- assert.deepEqual(surfaceCounts(), [0, 1], "no style, but the section still registers");
360
-
361
- const section = snapshotSections().find((candidate) => candidate.id === "bash-card.status");
362
- assert.ok(section !== undefined);
363
- const lines = section.lines({ theme: undefined, configHome });
364
- assert.equal(
365
- lines.includes("card style absent"),
366
- true,
367
- "the section never claims a surface that did not register",
368
- );
369
- assert.equal(lines.includes("resolver registered"), true);
370
- assert.equal(
371
- buildRows({ theme: undefined, configHome }).rows.some((row) => row.label === "Demo button"),
372
- true,
373
- "the demo category is untouched",
374
- );
375
-
376
- dispose();
377
- });
378
-
379
- test("T30: a late load through the ready announcement registers the style", async () => {
380
- const bus = createFakeEvents();
381
- const { pi, sessionHandlers } = recordingPi(bus);
382
- (await loadExample("plain-late"))(pi);
383
-
384
- assert.deepEqual(surfaceCounts(), [0, 0], "nothing registers at module load");
385
- assert.equal(bus.listenerCount(BRIDGE_READY_EVENT), 1, "the early loader is listening");
386
-
387
- loadBridge(bus);
388
-
389
- assert.deepEqual(surfaceCounts(), [1, 1], "the announcement served the early loader");
390
- assert.equal(bus.listenerCount(BRIDGE_READY_EVENT), 0, "and it stopped listening afterwards");
391
-
392
- const { ui } = notifySurface();
393
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
394
- assert.deepEqual(surfaceCounts(), [1, 1], "a later session_start adds no second copy");
395
- });
396
-
397
- test("T30: a reload replaces the style instead of stacking a second one, and the stable disposer removes it", async () => {
398
- publishBridgeApi();
399
- const { pi, sessionHandlers } = recordingPi();
400
- const { ui } = notifySurface();
401
-
402
- const first = (await loadExample("plain-reload-1"))(pi);
403
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
404
- assert.deepEqual(surfaceCounts(), [1, 1]);
405
-
406
- // A `/reload` loads the file again: the same id must replace, not duplicate.
407
- const second = (await loadExample("plain-reload-2"))(pi);
408
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
409
- assert.deepEqual(surfaceCounts(), [1, 1], "one `plain` value, not two");
410
-
411
- second();
412
- assert.deepEqual(surfaceCounts(), [0, 0], "the last load's disposer takes both replaced surfaces with it");
413
- first();
414
- assert.deepEqual(surfaceCounts(), [0, 0], "disposing a replaced registration changes nothing else");
415
- });
416
-
417
- test("T30: a stored selection of `bash-card.plain` waits as `missing`, then resolves once the style registers", async () => {
418
- setCardStyleSelection("bash-card.plain");
419
- assert.deepEqual(resolveCardStyleSelection(), {
420
- kind: "missing",
421
- id: "bash-card.plain",
422
- });
423
-
424
- const tui = createFakeTranscriptTui({ lines: [...READ_CARD] });
425
- installCardStyleTransform(tui);
426
- assert.deepEqual(tui.chat.render(40), [...READ_CARD], "an unresolvable selection draws no chrome");
427
- assert.deepEqual(getCardStyleLastOutcome("conversation"), {
428
- kind: "pass-through",
429
- reason: "missing",
430
- });
431
-
432
- publishBridgeApi();
433
- const { pi, sessionHandlers } = recordingPi();
434
- (await loadExample("plain-stored"))(pi);
435
- const { ui } = notifySurface();
436
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
437
-
438
- const resolved = resolveCardStyleSelection();
439
- assert.equal(resolved.kind, "style");
440
- if (resolved.kind !== "style") return;
441
- assert.equal(resolved.style.id, "bash-card.plain", "the stored selection resolves to the restored style");
442
- });
443
-
444
- // ---------------------------------------------------------------------------------------------
445
- // The matrix fixtures and the modelled host framer
446
- // ---------------------------------------------------------------------------------------------
447
-
448
- /** One host `read` card (`RAIL_HOST_CARD` shape) as the host's own framing draws it. */
449
- const RAIL_WIDTH = 40;
450
-
451
- /**
452
- * The host's own `float` section card at width 40, in the bytes `lib/shell-card.ts` produces for
453
- * `sectionCardRows` (`lib/shell-sidebar-layout.ts:35-47`): `floatRows` over `cardTop`, one `cardLine`
454
- * per body row and `cardBottom`, with the host's role map (`border` 90, `accent` 95, background
455
- * `\u001b[48;5;236m`) as `test/fake-sidebar.ts` recorded it.
456
- *
457
- * Proven against the two host-produced fixtures, not assumed: with `STATS` + the two native rows it
458
- * reproduces `RAIL_HOST_CARD` byte for byte, and with an empty body it reproduces that fixture minus
459
- * its two body rows (the collapsed section the host draws when a section is folded).
460
- */
461
- function hostSectionCard(title: string, body: readonly string[], width = RAIL_WIDTH): string[] {
462
- const bg = "\u001b[48;5;236m";
463
- const border = "\u001b[90m";
464
- const accent = "\u001b[95m";
465
- const reset = "\u001b[39m";
466
- const close = "\u001b[49m";
467
- const bar = `${border}▎${reset}`;
468
- const opener = ` ${bg}${bar}${border}${" ".repeat(width - 3)}${reset}${close} `;
469
- const header =
470
- ` ${bg}${bar}${border} ${reset}${accent}▾ ${title.slice(0, width - 8)}${reset}` +
471
- `${border}${" ".repeat(Math.max(0, width - 8 - title.length))}${reset}${border} ${reset}${close} `;
472
- const rows = body.map(
473
- (text) =>
474
- ` ${bg}${bar} ${text.slice(0, width - 5)}` +
475
- `${" ".repeat(Math.max(0, width - 5 - text.length))}${border} ${reset}${close} `,
476
- );
477
- return [opener, header, ...rows, opener];
478
- }
479
-
480
- /** Visible columns of a row, ANSI ignored; the host's own rows are exactly `width`. */
481
- function widthOf(line: string): number {
482
- return [...stripAnsi(line)].length;
483
- }
484
-
485
- const assertAtMost = (rows: readonly string[], width: number, label: string): void => {
486
- for (const [index, row] of rows.entries()) {
487
- assert.equal(
488
- widthOf(row) <= width,
489
- true,
490
- `${label}: row ${index} is never wider than the host's width`,
491
- );
492
- }
493
- };
494
-
495
- // ---------------------------------------------------------------------------------------------
496
- // The conversation matrix: real tool-design resolver + real card-style transform
497
- // ---------------------------------------------------------------------------------------------
498
-
499
- interface FakeComponent {
500
- render(width: number): string[];
501
- }
502
-
503
- interface FakeRenderers {
504
- renderShell?: unknown;
505
- renderCall?: unknown;
506
- renderResult?: unknown;
507
- [member: string]: unknown;
508
- }
509
-
510
- type Resolver = (tool: string, next: () => FakeRenderers) => FakeRenderers | undefined;
511
-
512
- function fakeComponent(lines: readonly string[]): FakeComponent {
513
- const rows = [...lines];
514
- return { render: (): string[] => rows };
515
- }
516
-
517
- function fakeRenderers(call: readonly string[], result: readonly string[]): FakeRenderers {
518
- const callComponent = fakeComponent(call);
519
- const resultComponent = fakeComponent(result);
520
- return {
521
- renderShell: "default",
522
- renderCall: () => callComponent,
523
- renderResult: () => resultComponent,
524
- };
525
- }
526
-
527
- function mount(renderers: FakeRenderers, member: "renderCall" | "renderResult"): FakeComponent {
528
- const renderer = renderers[member];
529
- assert.equal(typeof renderer, "function");
530
- return member === "renderCall"
531
- ? (renderer as (args: unknown, theme: unknown, ctx: unknown) => FakeComponent)(
532
- { command: "npm test" },
533
- { name: "fake-theme" },
534
- {},
535
- )
536
- : (renderer as (result: unknown, options: unknown, theme: unknown, ctx: unknown) => FakeComponent)(
537
- { content: [] },
538
- { expanded: false },
539
- { name: "fake-theme" },
540
- {},
541
- );
542
- }
543
-
544
- /** The host's own `Card style` rows, with the host's own setter semantics (write, then current). */
545
- function hostRows(): CustomizeRow[] {
546
- const state: { value: string } = { value: "float" };
547
- const styleRow = (value: string): CustomizeRow => ({
548
- category: "Cards",
549
- label: `Card style: ${value}`,
550
- action: () => {
551
- state.value = value;
552
- },
553
- choice: {
554
- setting: "Card style",
555
- value,
556
- current: () => state.value === value,
557
- },
558
- });
559
- return [
560
- { category: "Cards", label: "Card content: minimal", action: () => {} },
561
- styleRow("neon"),
562
- styleRow("float"),
563
- { category: "Layout", label: "Layout: builtin", action: () => {} },
564
- ];
565
- }
566
-
567
- interface World {
568
- host: FakeHost;
569
- resolver: Resolver;
570
- /** The host rows the conversation component returns; reassigning it is a host redraw. */
571
- setConversation(rows: readonly string[]): void;
572
- conversation(): readonly string[];
573
- setRail(rows: readonly string[]): void;
574
- rail(): readonly string[];
575
- renderConversation(width: number): string[];
576
- renderRail(): string[];
577
- rows(): CustomizeRow[];
578
- pressPlain(): Promise<void>;
579
- pressHost(value: "neon" | "float"): Promise<void>;
580
- status(): string[];
581
- notices(): string[];
582
- sessionStart(): void;
583
- dispose(): void;
584
- }
585
-
586
- /**
587
- * A whole session the way Pi runs it: the bridge's real entry (which publishes, arms the capture,
588
- * the widget, the conversation wrapper and the rail wrapper), the example registering through the
589
- * published object, the host's own menu rows, and one tui that carries both the conversation and
590
- * the rail the way Pi's `layoutRoot` does.
591
- */
592
- async function startWorld(tag: string, seed: { conversation: readonly string[]; rail: readonly string[] }): Promise<World> {
593
- publishBridgeApi();
594
- const host = createFakeHost({ rows: hostRows() });
595
-
596
- let conversationRows: string[] = [...seed.conversation];
597
- let railRows: string[] = [...seed.rail];
598
-
599
- const transcript = createFakeTranscriptTui({ lines: [] });
600
- // The wrapper wraps THIS function, so replacing the holder is what a host redraw looks like.
601
- transcript.chat.render = (): string[] => conversationRows;
602
-
603
- const stableRail: Rail = { render: () => railRows };
604
- const railTui = createRailTui({ rail: stableRail, contentWidth: RAIL_WIDTH });
605
-
606
- const tui = {
607
- ...railTui,
608
- children: transcript.children,
609
- document: transcript.document,
610
- chat: transcript.chat,
611
- };
612
-
613
- const resolvers: unknown[] = [];
614
- const sessionHandlers: SessionHandler[] = [];
615
- const notices: string[] = [];
616
- const pi: FakePi = {
617
- // `gentle-pi` is present, so the bridge's `session_start` arms every surface (S9).
618
- getCommands: () => [{ name: "gentle:customize" }],
619
- on(_event, handler) {
620
- sessionHandlers.push(handler);
621
- },
622
- registerCommand() {},
623
- registerToolRenderer(resolver) {
624
- resolvers.push(resolver);
625
- },
626
- };
627
-
628
- gentleShellBridge(pi as BridgeExtensionApi);
629
- const dispose = (await loadExample(tag))(pi);
630
-
631
- const ui = host.ui as Record<string, unknown>;
632
- ui.notify = (message: string): void => {
633
- notices.push(message);
634
- };
635
- ui.setWidget = (_key: string, factory: unknown): unknown => {
636
- assert.equal(typeof factory, "function");
637
- return (factory as (tuiArg: unknown, theme: unknown) => unknown)(tui, { name: "fake-theme" });
638
- };
639
-
640
- const sessionStart = (): void => {
641
- // The bridge and the example both subscribe; each gets the same context, as on Pi.
642
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
643
- };
644
- sessionStart();
645
-
646
- assert.equal(resolvers.length, 1, "the bridge registers exactly one tool-renderer resolver");
647
- const resolver = resolvers[0] as Resolver;
648
- assert.equal(typeof resolver, "function");
649
-
650
- // The host's own menu build is the capture moment: the bridge splices its values into the host's
651
- // `Card style` line and appends the example's rows.
652
- host.buildMenu();
653
-
654
- const rows = (): CustomizeRow[] => host.liveRows();
655
-
656
- return {
657
- host,
658
- resolver,
659
- setConversation: (rows) => {
660
- conversationRows = [...rows];
661
- },
662
- conversation: () => conversationRows,
663
- setRail: (rows) => {
664
- railRows = [...rows];
665
- },
666
- rail: () => railRows,
667
- renderConversation: (width) => tui.chat.render(width),
668
- renderRail: () => railTui.renderRail(),
669
- rows,
670
- pressPlain: async () => {
671
- await host.pressRow(
672
- (row) => row.choice?.setting === "Card style" && row.choice?.value === "plain",
673
- );
674
- },
675
- pressHost: async (value) => {
676
- await host.press(`Card style: ${value}`);
677
- },
678
- status: () => formatStatus(collectStatus()),
679
- notices: () => [...notices],
680
- sessionStart,
681
- dispose,
682
- };
683
- }
684
-
685
- // ---------------------------------------------------------------------------------------------
686
- // 1. conversation cards, content `minimal`, design native: the host rows, repainted chrome only
687
- // ---------------------------------------------------------------------------------------------
688
-
689
- test("T31 matrix — conversation/minimal/native: `plain` repaints bash and read tool cards and nothing else", async () => {
690
- const bash = [...BASH_MINIMAL_FLOAT_CALL, ...BASH_MINIMAL_FLOAT_RESULT];
691
- // Prose, a bash call+result card, and a `read` card: the frame both tools are drawn in.
692
- const conversation = ["a line the host wrote", "", ...bash, "", ...HOST_READ_CARD];
693
- const world = await startWorld("matrix-minimal-native", { conversation, rail: [] });
694
-
695
- // Design `native`: the bridge wraps the renderers (a design exists for bash), and the component
696
- // hands the host's own rows back byte for byte. No design at all (read) returns `next()` itself.
697
- const hostRenderers = fakeRenderers(BASH_MINIMAL_FLOAT_CALL, BASH_MINIMAL_FLOAT_RESULT);
698
- const resolvedBash = world.resolver("bash", () => hostRenderers);
699
- assert.ok(resolvedBash !== undefined);
700
- assert.deepEqual(mount(resolvedBash, "renderCall").render(60), [...BASH_MINIMAL_FLOAT_CALL]);
701
- assert.deepEqual(mount(resolvedBash, "renderResult").render(60), [...BASH_MINIMAL_FLOAT_RESULT]);
702
- const readRenderers = fakeRenderers(HOST_READ_CARD, []);
703
- assert.equal(world.resolver("read", () => readRenderers), readRenderers, "no design: nothing changes, not even identity");
704
-
705
- // With the style native the host's exact rows come back, by identity.
706
- assert.equal(world.renderConversation(60), world.conversation());
707
-
708
- await world.pressPlain();
709
- const painted = world.renderConversation(60);
710
-
711
- assert.deepEqual(
712
- painted.map((line) => stripAnsi(line)),
713
- [
714
- "a line the host wrote",
715
- "",
716
- "▌ npm test (timeout 30s) ctrl+o to expand",
717
- "▌ PASS 42 tests",
718
- "▌ duration 1.2s",
719
- "▌",
720
- "",
721
- "▌ read src/api.ts ctrl+o to expand",
722
- "▌ export function bridgeApi() {",
723
- "▌",
724
- ],
725
- "both tool cards are the plain frame: one gutter row per host row, every text kept, prose untouched",
726
- );
727
- assert.equal(painted.length, 10, "6 host rows become 4 for bash, 5 become 3 for read; prose and blanks stay");
728
- assertAtMost(painted, 60, "conversation/minimal/native");
729
- assert.deepEqual(getCardStyleLastOutcome("conversation"), {
730
- kind: "transformed",
731
- styleId: "bash-card.plain",
732
- cards: 2,
733
- });
734
-
735
- world.dispose();
736
- });
737
-
738
- // ---------------------------------------------------------------------------------------------
739
- // 2. conversation cards, content `minimal`, design `bash heading`: the word survives the repaint
740
- // ---------------------------------------------------------------------------------------------
741
-
742
- test("T31 matrix — conversation/minimal/bash heading: `bash` survives the plain repaint, widths exact, no row growth, host ANSI intact before it", async () => {
743
- const bash = [...BASH_MINIMAL_FLOAT_CALL, ...BASH_MINIMAL_FLOAT_RESULT];
744
- const world = await startWorld("matrix-minimal-designed", { conversation: [...bash], rail: [] });
745
- setToolDesignSelection("bash", "bash-card.bash-heading");
746
-
747
- const resolved = world.resolver(
748
- "bash",
749
- () => fakeRenderers(BASH_MINIMAL_FLOAT_CALL, BASH_MINIMAL_FLOAT_RESULT),
750
- );
751
- assert.ok(resolved !== undefined);
752
- const designedCall = mount(resolved, "renderCall").render(60);
753
- const designedResult = mount(resolved, "renderResult").render(60);
754
-
755
- assert.equal(designedCall.length, BASH_MINIMAL_FLOAT_CALL.length, "the design adds no row");
756
- assert.equal(
757
- stripAnsi(designedCall[1] ?? "").includes("bash npm test"),
758
- true,
759
- "the design heads the host's card with the word bash",
760
- );
761
- for (const [row, line] of [...designedCall, ...designedResult].entries()) {
762
- assert.equal(widthOf(line), 60, `designed row ${row} keeps the host's exact width`);
763
- }
764
- assert.deepEqual(
765
- designedResult,
766
- [...BASH_MINIMAL_FLOAT_RESULT],
767
- "the result component keeps the host's ANSI: the design only draws the call (S12)",
768
- );
769
- for (const [index, row] of designedResult.entries()) {
770
- assert.equal(row, BASH_MINIMAL_FLOAT_RESULT[index], `result row ${index} is the host's byte for byte`);
771
- }
772
-
773
- // The host redraws with the designed card, then the real conversation wrapper repaints it.
774
- world.setConversation([...designedCall, ...designedResult]);
775
- await world.pressPlain();
776
- const painted = world.renderConversation(60);
777
-
778
- assert.equal(
779
- stripAnsi(painted[0] ?? "").startsWith("▌ bash npm test (timeout 30s)"),
780
- true,
781
- "the word the design put on the heading is what the style draws",
782
- );
783
- assert.equal(
784
- stripAnsi(painted[0] ?? "").includes("$ npm test"),
785
- false,
786
- "the host's mark does not come back",
787
- );
788
- assert.equal(painted.length, 4, "no row growth: top + three body rows + bottom is the plain frame");
789
- assert.deepEqual(
790
- painted.map((line) => stripAnsi(line)).slice(1),
791
- ["▌ PASS 42 tests", "▌ duration 1.2s", "▌"],
792
- );
793
- assertAtMost(painted, 60, "conversation/minimal/bash heading");
794
-
795
- world.dispose();
796
- });
797
-
798
- // ---------------------------------------------------------------------------------------------
799
- // 3. conversation cards, content `default`: NOT reachable — pinned as pass-through, and reported
800
- // ---------------------------------------------------------------------------------------------
801
-
802
- test("T31 matrix — conversation/default: pi's native card is not a float frame, so neither the host style nor `plain` nor an added style reaches it (pass-through, same array)", async () => {
803
- const bash = [...BASH_DEFAULT_CALL, ...BASH_DEFAULT_RESULT];
804
- const world = await startWorld("matrix-default", { conversation: [...bash], rail: [] });
805
-
806
- await world.pressPlain();
807
- const passed = world.renderConversation(58);
808
- assert.equal(
809
- passed,
810
- world.conversation(),
811
- "pass-through by identity: the style never draws over pi's native rows",
812
- );
813
- assert.deepEqual(getCardStyleLastOutcome("conversation"), {
814
- kind: "pass-through",
815
- reason: "no-card",
816
- });
817
-
818
- // No HOST style reaches it either: pressing the host's own value leaves pi's native rows alone,
819
- // because nothing on pi's path reads `cardStyle()`.
820
- await world.pressHost("neon");
821
- assert.equal(world.renderConversation(58), world.conversation(), "neon: pi's native rows are untouched");
822
- await world.pressHost("float");
823
- assert.equal(world.renderConversation(58), world.conversation(), "float: the same rows");
824
- await world.pressPlain();
825
-
826
- // A style ADDED through registerCardStyle cannot reach it either: the repaint's grammar is the
827
- // float ` ▎ ` frame (`src/card-style-transform.ts:189-196`), and pi's native rows have none.
828
- const addedCalls: string[] = [];
829
- const { registerCardStyle } = await import("../src/bus.ts");
830
- registerCardStyle({
831
- id: "matrix.added",
832
- label: "added",
833
- frame: {
834
- top: (ctx) => {
835
- addedCalls.push("top");
836
- return `<${ctx.title ?? ""}>`;
837
- },
838
- line: (ctx) => {
839
- addedCalls.push("line");
840
- return ctx.text;
841
- },
842
- bottom: () => {
843
- addedCalls.push("bottom");
844
- return "_";
845
- },
846
- },
847
- });
848
- setCardStyleSelection("matrix.added");
849
- assert.equal(
850
- world.renderConversation(58),
851
- world.conversation(),
852
- "an added style is passed through too: same array instance",
853
- );
854
- assert.deepEqual(addedCalls, [], "its frame functions were never called for this cell");
855
-
856
- // The design still ran: with `bash heading` the native card heads with `bash`, and the repaint is
857
- // still a pass-through, ANSI bytes intact.
858
- setToolDesignSelection("bash", "bash-card.bash-heading");
859
- const resolved = world.resolver(
860
- "bash",
861
- () => fakeRenderers(BASH_DEFAULT_CALL, BASH_DEFAULT_RESULT),
862
- );
863
- assert.ok(resolved !== undefined);
864
- const designedCall = mount(resolved, "renderCall").render(58);
865
- assert.equal(stripAnsi(designedCall[0] ?? "").startsWith("bash npm test"), true);
866
- const designed = [...designedCall, ...mount(resolved, "renderResult").render(58)];
867
- world.setConversation(designed);
868
- const stillPassed = world.renderConversation(58);
869
- assert.equal(stillPassed, world.conversation(), "the design's rows reach the style, and come back untouched");
870
- assert.deepEqual(stillPassed.map((line) => line), [...designed], "every ANSI byte is intact");
871
-
872
- world.dispose();
873
- });
874
-
875
- // ---------------------------------------------------------------------------------------------
876
- // 4. the native sidebar (rail): host sections and the bridge's own sections, through the rail wiring
877
- // ---------------------------------------------------------------------------------------------
878
-
879
- test("T31 matrix — rail: host sections painted by the host (STATS, AGENTS) and the status rail are repainted through the real rail wrapper", async () => {
880
- // Two host-style panels, the first the host-produced `RAIL_HOST_CARD`, the second its AGENTS
881
- // sibling built by the proven framer; a blank row between cards is what the host's layout draws.
882
- const railFrame = [...RAIL_HOST_CARD, "", ...hostSectionCard("AGENTS", ["native row 1", "native row 2"])];
883
- const world = await startWorld("matrix-rail", { conversation: [], rail: railFrame });
884
-
885
- // The fixture the framer is proven against, first: the derivation is not an invention.
886
- assert.deepEqual(
887
- hostSectionCard("STATS", ["native row 1", "native row 2"]),
888
- [...RAIL_HOST_CARD],
889
- "hostSectionCard reproduces the host-produced RAIL_HOST_CARD byte for byte",
890
- );
891
- assert.deepEqual(
892
- hostSectionCard("STATS", []),
893
- [RAIL_HOST_CARD[0], RAIL_HOST_CARD[1], RAIL_HOST_CARD[4]],
894
- "a collapsed section is the same card without body rows",
895
- );
896
-
897
- assert.equal(world.renderRail(), world.rail(), "native: the rail's own array, by identity");
898
-
899
- await world.pressPlain();
900
- const painted = world.renderRail();
901
- assert.deepEqual(
902
- painted.map((line) => stripAnsi(line)),
903
- [
904
- "▌ STATS",
905
- "▌ native row 1",
906
- "▌ native row 2",
907
- "▌",
908
- "",
909
- "▌ AGENTS",
910
- "▌ native row 1",
911
- "▌ native row 2",
912
- "▌",
913
- ],
914
- "every card on the rail is repainted, the host's own STATS and AGENTS panels included",
915
- );
916
- assertAtMost(painted, RAIL_WIDTH, "rail");
917
- assert.deepEqual(getCardStyleLastOutcome("rail"), {
918
- kind: "transformed",
919
- styleId: "bash-card.plain",
920
- cards: 2,
921
- });
922
- assert.equal(
923
- painted.some((line) => stripAnsi(line).includes("▎")),
924
- false,
925
- "no host float chrome is carried into a repainted rail card",
926
- );
927
-
928
- world.dispose();
929
- });
930
-
931
- // ---------------------------------------------------------------------------------------------
932
- // 5. sections added by the bridge/another plugin, collapsed and expanded, header/tones/hit map
933
- // ---------------------------------------------------------------------------------------------
934
-
935
- /** The theme the host's roles paint with, matching `test/section-panel.test.ts`'s reference. */
936
- const ROLE_SGR: Record<string, number> = {
937
- text: 37,
938
- muted: 90,
939
- dim: 2,
940
- accent: 95,
941
- success: 32,
942
- error: 31,
943
- warning: 33,
944
- };
945
-
946
- function fakeTheme(): { fg(role: string, text: string): string; bold(text: string): string } {
947
- return {
948
- fg: (role, text) => `\u001b[${ROLE_SGR[role] ?? 0}m${text}\u001b[39m`,
949
- bold: (text) => `\u001b[1m${text}\u001b[22m`,
950
- };
951
- }
952
-
953
- /**
954
- * Frame the rows the wrapped host part returns into the host's own float cards, group by group, the
955
- * way the host's layout does (`sectionCardRows`, `lib/shell-sidebar-layout.ts:35-47`). The rows and
956
- * the groups come from the REAL sidebar backend wrapper and the REAL section renderer; only the
957
- * framing arithmetic is the model above, pinned against the host fixtures.
958
- */
959
- function framePart(part: { render(width: number): string[]; cardGroups?(width: number): { groups: Array<{ start: number; end: number }> } | undefined }, width: number): string[] {
960
- const lines = part.render(width);
961
- const groups = part.cardGroups?.(width)?.groups ?? [{ start: 0, end: lines.length }];
962
- const framed: string[] = [];
963
- groups.forEach((group, index) => {
964
- if (index > 0) framed.push("");
965
- const header = stripAnsi(lines[group.start] ?? "").trimEnd();
966
- // The host splits the header into glyph + title + right-aligned count (`headerParts`, which is
967
- // why the two runs are separated by the host's own gap); the model frames the title, and the
968
- // count is asserted on the section contribution itself, where the section owns it.
969
- const withoutGlyph = header.replace(/^\S+\s+/u, "");
970
- const title = withoutGlyph.split(/\s{2,}/u)[0] ?? withoutGlyph;
971
- const body = lines.slice(group.start + 1, group.end).map((row) => stripAnsi(row).trimEnd());
972
- framed.push(...hostSectionCard(title, body, width));
973
- });
974
- return framed;
975
- }
976
-
977
- test("T31 matrix — sections: the bridge's own section and another plugin's, collapsed and expanded, keep their header, tones and hit map; only the chrome changes", async () => {
978
- const world = await startWorld("matrix-sections", { conversation: [], rail: [] });
979
- const theme = fakeTheme();
980
-
981
- // A section added through the PUBLIC api, exactly as another plugin would.
982
- const api = bridgeApi();
983
- assert.ok(api !== undefined);
984
- const disposeMusic = api.registerSection({
985
- id: "loon.music",
986
- title: "MUSIC",
987
- lines: () => ["track Blue in Green", { glyph: "♪", label: "playing", meta: "5:37", tone: "accent" }],
988
- defaultVisible: true,
989
- });
990
-
991
- // The host's own footer part, registered before the arm, wrapped by the bridge's real backend.
992
- registerNativeFooter();
993
- world.sessionStart();
994
- // The widget factory hands the bridge the host's theme; this session's theme is ours, set AFTER
995
- // the arm so the section renderer paints the host's roles with it.
996
- setSectionTheme(theme);
997
- const part = fakeState().parts.get(SIDEBAR_RAIL_KEY);
998
- assert.ok(part !== undefined, "the host's footer part is wrapped in place");
999
-
1000
- const ctx = { theme, configHome };
1001
-
1002
- /** The contribution rows and groups the real renderer builds right now. */
1003
- const contribution = (): { rows: string[]; groups: Array<{ start: number; end: number }> } => {
1004
- const built = buildSectionContribution(RAIL_WIDTH, ctx);
1005
- assert.ok(built !== undefined, "both sections are visible, so the rail has rows");
1006
- return { rows: built.lines, groups: built.groups };
1007
- };
1008
-
1009
- const headerRow = (rows: readonly string[], text: string): number => {
1010
- const index = rows.findIndex((row) => stripAnsi(row).includes(text));
1011
- assert.notEqual(index, -1, `the contribution carries the ${text} header`);
1012
- return index;
1013
- };
1014
-
1015
- // --- expanded ------------------------------------------------------------------------------
1016
- const expanded = contribution();
1017
- const exampleHeader = headerRow(expanded.rows, "CARD BASH");
1018
- const musicHeader = headerRow(expanded.rows, "MUSIC");
1019
- assert.ok(
1020
- expanded.rows.some((row) => row.includes("\u001b[95m") || row.includes("\u001b[90m")),
1021
- "the rows are painted with the host's roles: tones are part of the section, not the style",
1022
- );
1023
- assert.equal(
1024
- stripAnsi(expanded.rows[exampleHeader] ?? "").includes("native"),
1025
- true,
1026
- "the example's section header carries its live meta",
1027
- );
1028
-
1029
- const expandedPart = framePart(part, RAIL_WIDTH);
1030
- await world.pressPlain();
1031
- assert.equal(
1032
- world.renderRail(),
1033
- world.renderRail(),
1034
- "rendering the rail is stable; the style is not re-entrant",
1035
- );
1036
- const paintedExpanded = transformCardLines([...expandedPart], RAIL_WIDTH, "rail");
1037
- assert.equal(
1038
- paintedExpanded.some((row) => stripAnsi(row).includes("CARD BASH")),
1039
- true,
1040
- "the example's header survives the style",
1041
- );
1042
- assert.equal(
1043
- paintedExpanded.some((row) => stripAnsi(row).includes("MUSIC")),
1044
- true,
1045
- "so does the other plugin's header",
1046
- );
1047
- assert.equal(
1048
- paintedExpanded.some((row) => stripAnsi(row).includes("track Blue in Green")),
1049
- true,
1050
- "and its body rows",
1051
- );
1052
- assert.deepEqual(
1053
- contribution(),
1054
- expanded,
1055
- "the style is downstream chrome: it never rebuilds the contribution, its tones or its groups",
1056
- );
1057
-
1058
- // The hit map still works while the style is selected: the header rows are the same rows.
1059
- const nativeRows = 3; // the host's own FOOTER: header + two body rows
1060
- const clicked = part.handleMouse?.({
1061
- type: "click",
1062
- button: "left",
1063
- x: 4,
1064
- y: nativeRows + exampleHeader,
1065
- width: RAIL_WIDTH,
1066
- height: 40,
1067
- });
1068
- assert.deepEqual(clicked, { handled: true, render: true }, "the section header still answers the pointer");
1069
-
1070
- // --- collapsed -----------------------------------------------------------------------------
1071
- setSectionCollapsed("bash-card.status", true);
1072
- setSectionCollapsed("loon.music", true);
1073
- const collapsed = contribution();
1074
- assert.equal(
1075
- collapsed.rows.length < expanded.rows.length,
1076
- true,
1077
- "a collapsed section builds no body rows (folding costs no work)",
1078
- );
1079
- assert.deepEqual(
1080
- collapsed.groups,
1081
- [
1082
- { start: 0, end: 1 },
1083
- { start: 1, end: 2 },
1084
- ],
1085
- "one header row per section, no body",
1086
- );
1087
- const collapsedPart = framePart(part, RAIL_WIDTH);
1088
- const paintedCollapsed = transformCardLines([...collapsedPart], RAIL_WIDTH, "rail");
1089
- assert.deepEqual(
1090
- paintedCollapsed.filter((row) => row.trim() !== "").map((row) => stripAnsi(row)),
1091
- ["▌ FOOTER", "▌ native row 1", "▌ native row 2", "▌", "▌ CARD BASH", "▌", "▌ MUSIC", "▌"],
1092
- "the collapsed sections are the plain frame around the same headers; no body row is invented (the " +
1093
- "host's own FOOTER card keeps its body, because the host, not the section, was folded)",
1094
- );
1095
- assert.deepEqual(
1096
- contribution(),
1097
- collapsed,
1098
- "collapsed or expanded, the style never changes what the section renderer produces",
1099
- );
1100
-
1101
- // Expanded again: exactly the rows that were there before, so the fold is reversible.
1102
- setSectionCollapsed("bash-card.status", false);
1103
- setSectionCollapsed("loon.music", false);
1104
- assert.deepEqual(contribution(), expanded, "expanding restores the same rows, tones and groups");
1105
-
1106
- disposeMusic();
1107
- void musicHeader;
1108
- world.dispose();
1109
- });
1110
-
1111
- // A second plugin's section, as the section row model draws it, so the tone assertion is explicit.
1112
- test("T31 matrix — sections: a structured row keeps its tone through the style, because the style draws chrome and passes no tone", async () => {
1113
- const world = await startWorld("matrix-sections-tone", { conversation: [], rail: [] });
1114
- const theme = fakeTheme();
1115
- const api = bridgeApi();
1116
- assert.ok(api !== undefined);
1117
- const dispose = api.registerSection({
1118
- id: "loon.music",
1119
- title: "MUSIC",
1120
- lines: () => [{ glyph: "♪", label: "playing", meta: "5:37", tone: "accent" }],
1121
- defaultVisible: true,
1122
- });
1123
-
1124
- registerNativeFooter();
1125
- world.sessionStart();
1126
- setSectionTheme(theme);
1127
- const part = fakeState().parts.get(SIDEBAR_RAIL_KEY);
1128
- assert.ok(part !== undefined);
1129
-
1130
- const built = buildSectionContribution(RAIL_WIDTH, { theme, configHome });
1131
- assert.ok(built !== undefined);
1132
- const accentRow = built.lines.find((row) => stripAnsi(row).includes("playing"));
1133
- assert.ok(accentRow !== undefined);
1134
- assert.equal(
1135
- accentRow.includes("\u001b[95m"),
1136
- true,
1137
- "the accent tone is on the section row before the style sees anything",
1138
- );
1139
-
1140
- await world.pressPlain();
1141
- const painted = transformCardLines(framePart(part, RAIL_WIDTH), RAIL_WIDTH, "rail");
1142
- assert.equal(
1143
- painted.some((row) => stripAnsi(row).includes("playing")),
1144
- true,
1145
- "the style redraws the text without the tone: chrome is the style's, tone is the section's",
1146
- );
1147
-
1148
- dispose();
1149
- world.dispose();
1150
- });
1151
-
1152
- // ---------------------------------------------------------------------------------------------
1153
- // 6. switching back to the host's own values restores every surface's exact bytes
1154
- // ---------------------------------------------------------------------------------------------
1155
-
1156
- test("T31 matrix — switching back to `neon` and to `float` restores the host's exact bytes on the conversation and the rail (by identity)", async () => {
1157
- const bash = [...BASH_MINIMAL_FLOAT_CALL, ...BASH_MINIMAL_FLOAT_RESULT];
1158
- const railFrame = [...RAIL_HOST_CARD, "", ...RAIL_HOST_CARD];
1159
- const world = await startWorld("matrix-back", { conversation: bash, rail: railFrame });
1160
-
1161
- for (const hostValue of ["neon", "float"] as const) {
1162
- await world.pressPlain();
1163
- assert.notEqual(world.renderConversation(60), world.conversation(), "plain is drawing");
1164
- assert.notEqual(world.renderRail(), world.rail(), "and it reaches the rail too");
1165
-
1166
- // Pressing the host's own value is the way back (S13): the bridge selection becomes `native`.
1167
- await world.pressHost(hostValue);
1168
- assert.equal(getCardStyleSelection(), "native", `${hostValue}: the bridge no longer touches the frame`);
1169
- assert.equal(world.renderConversation(60), world.conversation(), `${hostValue}: the conversation's array`);
1170
- assert.equal(world.renderRail(), world.rail(), `${hostValue}: the rail's array`);
1171
- }
1172
-
1173
- world.dispose();
1174
- });
1175
-
1176
- // ---------------------------------------------------------------------------------------------
1177
- // 7. /bridge-status says, per surface, whether the style reached it
1178
- // ---------------------------------------------------------------------------------------------
1179
-
1180
- test("T31 matrix — /bridge-status reports reach and the last frame per surface, including the pass-through cell", async () => {
1181
- const bash = [...BASH_MINIMAL_FLOAT_CALL, ...BASH_MINIMAL_FLOAT_RESULT];
1182
- const world = await startWorld("matrix-status", { conversation: bash, rail: [...RAIL_HOST_CARD] });
1183
-
1184
- await world.pressPlain();
1185
- const painted = world.renderConversation(60);
1186
- assert.equal(painted.length > 0, true);
1187
- world.renderRail();
1188
-
1189
- const lines = world.status();
1190
- assert.equal(
1191
- lines.includes(
1192
- "card style surface conversation: reached (chat wrapped), last frame transformed 1 card",
1193
- ),
1194
- true,
1195
- "the conversation surface reports that the style reached it and what it did",
1196
- );
1197
- assert.equal(
1198
- lines.some((line) =>
1199
- /^card style surface rail: reached \(rail wrapped\), last frame transformed 1 card$/u.test(line),
1200
- ),
1201
- true,
1202
- "the rail surface reports its own reach, separately",
1203
- );
1204
-
1205
- // Content `default`: the conversation says pass-through, so the unreachable cell has an answer.
1206
- setCardStyleSelection("bash-card.plain");
1207
- world.setConversation([...BASH_DEFAULT_CALL, ...BASH_DEFAULT_RESULT]);
1208
- world.renderConversation(58);
1209
- assert.equal(
1210
- world
1211
- .status()
1212
- .includes("card style surface conversation: reached (chat wrapped), last frame pass-through (no card)"),
1213
- true,
1214
- "the cell a style cannot reach is reported, not hidden",
1215
- );
1216
-
1217
- world.dispose();
1218
- });
1219
-
1220
- // ---------------------------------------------------------------------------------------------
1221
- // T30 — the suites that were deleted with the style, re-covered end to end
1222
- // ---------------------------------------------------------------------------------------------
1223
- test("T30: two surfaces, one identity — a section line reports the style as registered, never as assumed", async () => {
1224
- publishBridgeApi();
1225
- const { pi, sessionHandlers } = recordingPi();
1226
- (await loadExample("plain-section-state"))(pi);
1227
- const { ui } = notifySurface();
1228
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
1229
-
1230
- const section = snapshotSections().find((candidate) => candidate.id === "bash-card.status");
1231
- assert.ok(section !== undefined);
1232
- const lines = section.lines({ theme: undefined, configHome });
1233
- assert.equal(lines.includes("card style registered"), true);
1234
- assert.equal(
1235
- lines.some((line) => typeof line !== "string" && line.label === "selected"),
1236
- false,
1237
- "the live selection is the header meta only, never a duplicate body row",
1238
- );
1239
- assert.equal(section.meta?.({ theme: undefined, configHome }), "native");
1240
- });
1241
-
1242
- test("T30: no bridge at all adds nothing and reports it once, with the style named as one of the missing surfaces", async () => {
1243
- const { pi, sessionHandlers } = recordingPi();
1244
- (await loadExample("plain-no-bridge"))(pi);
1245
- const { ui, calls } = notifySurface();
1246
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
1247
-
1248
- assert.equal(calls.length, 1);
1249
- assert.match(calls[0][0], /adds nothing/u);
1250
- assert.match(calls[0][0], /no plain card style/u, "the message names the restored surface");
1251
- assert.deepEqual(surfaceCounts(), [0, 0]);
1252
- });
1253
-
1254
- test("T30: the announcement alone is enough to register `plain`, with no session_start", async () => {
1255
- const bus = createFakeEvents();
1256
- const { pi, sessionHandlers } = recordingPi(bus);
1257
- (await loadExample("plain-late-no-session"))(pi);
1258
-
1259
- publishBridgeApi();
1260
- announceBridgeReady(bus);
1261
-
1262
- assert.deepEqual(surfaceCounts(), [1, 1]);
1263
- assert.equal(sessionHandlers.length, 1, "the handler stays for the next session");
1264
-
1265
- const { ui, calls } = notifySurface();
1266
- for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
1267
- assert.deepEqual(calls, [], "nothing pending: the surfaces registered");
1268
- });