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,745 +0,0 @@
1
- /**
2
- * The per-tool DESIGN bus (S4–S7, T7, T8, T11).
3
- *
4
- * ONE `pi.registerToolRenderer` resolver is installed by the bridge, and it fans out to the designs
5
- * plugins registered for a tool name. The resolver runs ONCE per card, at construction, so its
6
- * wrapper must be the thing that decides: for every tool with at least one registered design it
7
- * wraps the host's own `renderCall`/`renderResult` components, and on EVERY render pass the wrapper
8
- * reads the live selection. With no design selected (or a design that disappeared) it renders the
9
- * host's component and returns its lines byte for byte; with a design selected it hands those lines
10
- * to the design's `apply`. That is what lets a design chosen AFTER a card was drawn repaint it
11
- * (S2).
12
- *
13
- * Everything asserted here is observable through the published surface: the resolver function, the
14
- * renderers it returns, the components those renderers build, and the failure/conflict records
15
- * `/bridge-status` prints. No internal field is read.
16
- */
17
-
18
- import assert from "node:assert/strict";
19
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
22
- import test from "node:test";
23
-
24
- import { armBridge } from "../src/arm.ts";
25
- import { bridgeApi } from "../src/api.ts";
26
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
27
- import gentleShellBridge, { type BridgeExtensionApi } from "../src/extension.ts";
28
- import { resetHookStateForTests } from "../src/hook.ts";
29
- import { resetNoticesForTests } from "../src/notices.ts";
30
- import { resetPluginLinesForTests } from "../src/plugin-lines.ts";
31
- import { resetRepaintForTests } from "../src/repaint.ts";
32
- import { resetRowBuildStateForTests } from "../src/rows.ts";
33
- import { collectStatus, formatStatus } from "../src/status.ts";
34
- import { resetStoreStateForTests } from "../src/store.ts";
35
- import {
36
- TOOL_DESIGN_NATIVE,
37
- TOOL_DESIGN_STORE_ID,
38
- getToolDesignConflicts,
39
- getToolDesignFailures,
40
- getToolDesignSelection,
41
- registerToolDesign,
42
- resetToolDesignsForTests,
43
- resolveToolDesignSelection,
44
- setToolDesignSelection,
45
- snapshotToolDesigns,
46
- toolNamesWithDesigns,
47
- } from "../src/tool-designs.ts";
48
- import type { ToolDesignContext, ToolDesignRegistration } from "../src/types.ts";
49
- import {
50
- installToolDesignResolver,
51
- resetToolDesignWiringForTests,
52
- toolDesignResolver,
53
- } from "../src/tool-design-wiring.ts";
54
- import { createFakeHost } from "./fake-host.ts";
55
-
56
- let configHome: string;
57
- let previousConfigHome: string | undefined;
58
-
59
- test.before(() => {
60
- configHome = mkdtempSync(join(tmpdir(), "gsb-tool-designs-"));
61
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
62
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
63
- });
64
-
65
- test.after(() => {
66
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
67
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
68
- rmSync(configHome, { recursive: true, force: true });
69
- });
70
-
71
- const resetAll = (): void => {
72
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
73
- resetToolDesignsForTests();
74
- resetToolDesignWiringForTests();
75
- resetRepaintForTests();
76
- resetStoreStateForTests();
77
- resetNoticesForTests();
78
- resetPluginLinesForTests();
79
- resetRowBuildStateForTests();
80
- resetCaptureStateForTests();
81
- resetHookStateForTests();
82
- disableCaptureBackend();
83
- };
84
-
85
- test.beforeEach(resetAll);
86
- test.afterEach(disableCaptureBackend);
87
-
88
- // ---------------------------------------------------------------------------------------------
89
- // A host-like card: the rows the host itself would draw, ANSI and padding included
90
- // ---------------------------------------------------------------------------------------------
91
-
92
- const THEME = { name: "fake-theme", fg: (role: string, text: string) => `${role}:${text}` };
93
-
94
- /** The four rows the host draws for a bash call: frame top + hint, `$` heading, body, frame bottom. */
95
- const BASH_CARD = [
96
- "╭─ ✿ bash card ───────────── ctrl+o ╮",
97
- "│ \u001b[36m$ ls -la\u001b[0m ",
98
- "│ total 4 ",
99
- "╰───────────────────────────────────╯",
100
- ];
101
-
102
- /** `BASH_CARD` with only the heading mark rewritten, i.e. what `bashHeadingDesign` draws. */
103
- const [BASH_HEADING_BEFORE, BASH_HEADING_AFTER] = BASH_CARD[1].split("$ ");
104
- const DESIGNED_BASH_CARD = [
105
- BASH_CARD[0],
106
- `${BASH_HEADING_BEFORE}bash ${BASH_HEADING_AFTER}`,
107
- BASH_CARD[2],
108
- BASH_CARD[3],
109
- ];
110
-
111
- /** The second tool's card, so "applies to that tool only" is observable. */
112
- const WRITE_CARD = ["╭─ ✿ write ─╮", "│ ✎ src/index.ts", "╰───────────╯"];
113
-
114
- interface FakeComponent {
115
- render(width: number): string[];
116
- invalidate(): void;
117
- renders: () => number;
118
- lastWidth: () => number | undefined;
119
- }
120
-
121
- function fakeComponent(lines: readonly string[]): FakeComponent {
122
- let renders = 0;
123
- let lastWidth: number | undefined;
124
- return {
125
- render(width: number): string[] {
126
- renders += 1;
127
- lastWidth = width;
128
- return [...lines];
129
- },
130
- invalidate(): void {},
131
- renders: () => renders,
132
- lastWidth: () => lastWidth,
133
- };
134
- }
135
-
136
- const BASH_HEADING = "bash heading";
137
-
138
- /** The design the user asked for: head the card `bash` instead of the host's `$ ` prompt. */
139
- const bashHeadingDesign: ToolDesignRegistration = {
140
- tool: "bash",
141
- id: "loon.bash-heading",
142
- label: BASH_HEADING,
143
- description: "heads the card with bash instead of the host's $ prompt",
144
- preview: () => ({ title: "Cards · bash heading", sample: "bash ls -la" }),
145
- apply(lines: readonly string[], ctx: ToolDesignContext): string[] {
146
- assert.equal(ctx.tool, "bash");
147
- return lines.map((line) => (line.includes("$ ") ? line.replace("$ ", "bash ") : line));
148
- },
149
- };
150
-
151
- const writeBadgeDesign: ToolDesignRegistration = {
152
- tool: "write",
153
- id: "loon.write-badge",
154
- label: "write badge",
155
- apply: (lines: readonly string[]): string[] =>
156
- lines.map((line) => (line.includes("✎") ? line.replace("✎", "WRITE") : line)),
157
- };
158
-
159
- /** A renderer chain shaped like the host's: two components, one per renderer. */
160
- interface FakeRenderers {
161
- renderShell?: unknown;
162
- renderCall?: unknown;
163
- renderResult?: unknown;
164
- [member: string]: unknown;
165
- }
166
-
167
- function fakeRenderers(cards: { call: readonly string[]; result: readonly string[] }): {
168
- renderers: FakeRenderers;
169
- call: FakeComponent;
170
- result: FakeComponent;
171
- } {
172
- const call = fakeComponent(cards.call);
173
- const result = fakeComponent(cards.result);
174
- return {
175
- renderers: {
176
- renderShell: "default",
177
- renderCall: (_args: unknown, _theme: unknown, _ctx: unknown) => call,
178
- renderResult: (_result: unknown, _options: unknown, _theme: unknown, _ctx: unknown) => result,
179
- },
180
- call,
181
- result,
182
- };
183
- }
184
-
185
- const bashRenderers = () =>
186
- fakeRenderers({
187
- call: BASH_CARD,
188
- result: ["╭─ ✿ bash ─╮", "│ done", "╰──────────╯"],
189
- });
190
-
191
- const writeRenderers = () =>
192
- fakeRenderers({ call: WRITE_CARD, result: ["╭─ ✿ write ─╮", "│ ok", "╰───────────╯"] });
193
-
194
- /** The arguments the host passes that renderer, with the theme at its documented position. */
195
- const rendererArgs = (member: "renderCall" | "renderResult"): unknown[] =>
196
- member === "renderCall"
197
- ? [{ command: "ls -la" }, THEME, { args: {} }]
198
- : [{ content: [] }, { expanded: false }, THEME, { args: {} }];
199
-
200
- /**
201
- * Build the component Pi KEEPS. Pi invokes the resolved renderer once, when it constructs the card
202
- * (`getRegisteredToolDefinition(toolName)` → `new ToolExecutionComponent(...)`), and re-renders that
203
- * same object on every later frame. So re-rendering THIS object is what "repaint a card already
204
- * drawn" means, and a test that builds a new component cannot observe the defect.
205
- */
206
- const mountCard = (
207
- renderers: FakeRenderers,
208
- member: "renderCall" | "renderResult" = "renderCall",
209
- ): FakeComponent => {
210
- const renderer = renderers[member];
211
- assert.equal(typeof renderer, "function");
212
- return (renderer as (...values: unknown[]) => FakeComponent)(...rendererArgs(member));
213
- };
214
-
215
- /** Render one renderer's component the way the host does: `component.render(width)`. */
216
- const renderThrough = (
217
- renderers: FakeRenderers,
218
- member: "renderCall" | "renderResult",
219
- width: number,
220
- ): { lines: string[]; theme: unknown } => ({
221
- lines: mountCard(renderers, member).render(width),
222
- theme: THEME,
223
- });
224
-
225
- // ---------------------------------------------------------------------------------------------
226
- // T7 — one registry per tool name, conflicts reported
227
- // ---------------------------------------------------------------------------------------------
228
-
229
- test("T7: designs are kept per tool name, in registration order", () => {
230
- registerToolDesign(bashHeadingDesign);
231
- registerToolDesign(writeBadgeDesign);
232
-
233
- assert.deepEqual(
234
- snapshotToolDesigns("bash").map((design) => design.id),
235
- ["loon.bash-heading"],
236
- );
237
- assert.deepEqual(
238
- snapshotToolDesigns("write").map((design) => design.id),
239
- ["loon.write-badge"],
240
- );
241
- assert.deepEqual(toolNamesWithDesigns(), ["bash", "write"], "one registry per tool name");
242
- assert.deepEqual(snapshotToolDesigns("todo"), [], "a tool with no design has no registry entry");
243
- assert.deepEqual(getToolDesignConflicts(), [], "nothing was replaced or refused");
244
- });
245
-
246
- test("T7: a second registration for the same tool and id replaces in place and is REPORTED", () => {
247
- const first = registerToolDesign(bashHeadingDesign);
248
- const replacement: ToolDesignRegistration = { ...bashHeadingDesign, label: "bash command" };
249
- registerToolDesign(replacement);
250
-
251
- assert.deepEqual(
252
- snapshotToolDesigns("bash").map((design) => design.id),
253
- ["loon.bash-heading"],
254
- "there is still exactly one value for that id",
255
- );
256
- assert.equal(snapshotToolDesigns("bash")[0], replacement, "the later registration is the live one");
257
- const conflicts = getToolDesignConflicts();
258
- assert.equal(conflicts.length, 1, "the replacement is reported instead of silently happening");
259
- assert.equal(conflicts[0].tool, "bash");
260
- assert.equal(conflicts[0].id, "loon.bash-heading");
261
- assert.equal(conflicts[0].kind, "replaced");
262
-
263
- // The replaced registration's disposer cannot remove the replacement.
264
- first();
265
- assert.equal(snapshotToolDesigns("bash").length, 1);
266
- });
267
-
268
- test("T7: two designs of one tool cannot share a visible label, and the clash is reported", () => {
269
- registerToolDesign(bashHeadingDesign);
270
-
271
- assert.throws(
272
- () => registerToolDesign({ ...bashHeadingDesign, id: "loon.other", apply: (lines) => [...lines] }),
273
- /already used by "loon\.bash-heading"/u,
274
- );
275
- assert.deepEqual(
276
- snapshotToolDesigns("bash").map((design) => design.id),
277
- ["loon.bash-heading"],
278
- "a refused registration never half-registers",
279
- );
280
- assert.equal(getToolDesignConflicts().length, 1);
281
- assert.equal(getToolDesignConflicts()[0].kind, "label");
282
-
283
- // `native` is the row's own "the tool's own card" value, so a design may not read it too.
284
- assert.throws(
285
- () => registerToolDesign({ ...bashHeadingDesign, id: "loon.native", label: TOOL_DESIGN_NATIVE }),
286
- /native/u,
287
- );
288
- // The same id in two different tools is NOT a conflict: identity is (tool, id).
289
- registerToolDesign({ ...writeBadgeDesign, id: bashHeadingDesign.id });
290
- assert.deepEqual(toolNamesWithDesigns(), ["bash", "write"]);
291
- });
292
-
293
- test("T7: a malformed registration throws before anything is stored", () => {
294
- for (const invalid of [
295
- { ...bashHeadingDesign, tool: "" },
296
- { ...bashHeadingDesign, id: "" },
297
- { ...bashHeadingDesign, label: "" },
298
- { ...bashHeadingDesign, apply: undefined },
299
- ]) {
300
- assert.throws(() => registerToolDesign(invalid as unknown as ToolDesignRegistration));
301
- }
302
- assert.deepEqual(toolNamesWithDesigns(), []);
303
- });
304
-
305
- test("T7: the published API carries the registration a plugin that never imports the package needs", () => {
306
- const api = bridgeApi();
307
- assert.equal(typeof api?.registerToolDesign, "function", "the slot publishes the bus");
308
- api?.registerToolDesign?.(bashHeadingDesign);
309
- assert.deepEqual(
310
- snapshotToolDesigns("bash").map((design) => design.id),
311
- ["loon.bash-heading"],
312
- "it is the working function, not a stale copy",
313
- );
314
- });
315
-
316
- test("T7: /bridge-status reports the tools, the selection and every conflict", () => {
317
- registerToolDesign(bashHeadingDesign);
318
- setToolDesignSelection("bash", "loon.bash-heading");
319
- // A second registration for the same (tool, id) is the reportable conflict.
320
- registerToolDesign({ ...bashHeadingDesign, label: "bash command" });
321
-
322
- const snapshot = collectStatus();
323
- assert.ok(snapshot.toolDesigns !== undefined, "a session with designs reports the block");
324
- assert.deepEqual(
325
- snapshot.toolDesigns.tools.map((entry) => entry.tool),
326
- ["bash"],
327
- );
328
- assert.equal(snapshot.toolDesigns.tools[0].selection, "loon.bash-heading");
329
- assert.equal(snapshot.toolDesigns.conflicts[0].kind, "replaced");
330
-
331
- const report = formatStatus(snapshot).join("\n");
332
- assert.match(report, /tool designs: 1 tools with designs/u);
333
- assert.match(report, /loon\.bash-heading "bash command"/u);
334
- assert.match(report, /tool design conflicts: 1/u);
335
- assert.match(report, /tool design resolver: /u);
336
- });
337
-
338
- test("T7/S2: /bridge-status says which design resolves AND whether that tool's cards are wrapped", () => {
339
- registerToolDesign(bashHeadingDesign);
340
-
341
- let snapshot = collectStatus();
342
- assert.ok(snapshot.toolDesigns !== undefined);
343
- assert.equal(snapshot.toolDesigns.tools[0].selection, TOOL_DESIGN_NATIVE);
344
- assert.equal(
345
- snapshot.toolDesigns.tools[0].wrapper,
346
- "not mounted",
347
- "no card of this tool has been built through the wrapper yet",
348
- );
349
- assert.match(formatStatus(snapshot).join("\n"), /- bash: native, wrapper not mounted/u);
350
-
351
- // One card is constructed while a design is selected: now the wrapper IS mounted on a card.
352
- setToolDesignSelection("bash", "loon.bash-heading");
353
- const { renderers } = bashRenderers();
354
- const resolved = toolDesignResolver("bash", () => renderers);
355
- assert.ok(resolved !== undefined);
356
- mountCard(resolved).render(80);
357
-
358
- snapshot = collectStatus();
359
- assert.ok(snapshot.toolDesigns !== undefined);
360
- assert.equal(snapshot.toolDesigns.tools[0].selection, "loon.bash-heading");
361
- assert.equal(snapshot.toolDesigns.tools[0].wrapper, "mounted");
362
- assert.equal(snapshot.toolDesigns.tools[0].cards, 1, "one component was built through the wrapper");
363
- assert.match(
364
- formatStatus(snapshot).join("\n"),
365
- /- bash: loon\.bash-heading, wrapper mounted on 1 card/u,
366
- );
367
- });
368
-
369
- test("T7: the selection is persisted per TOOL, in the bridge's own store", () => {
370
- registerToolDesign(bashHeadingDesign);
371
- registerToolDesign(writeBadgeDesign);
372
-
373
- assert.equal(getToolDesignSelection("bash"), TOOL_DESIGN_NATIVE, "the tool's own card by default");
374
- setToolDesignSelection("bash", "loon.bash-heading");
375
- setToolDesignSelection("write", "loon.write-badge");
376
-
377
- assert.equal(getToolDesignSelection("bash"), "loon.bash-heading");
378
- assert.equal(getToolDesignSelection("write"), "loon.write-badge");
379
- assert.equal(getToolDesignSelection("edit"), TOOL_DESIGN_NATIVE, "each tool has its own value");
380
-
381
- const stored = JSON.parse(
382
- readFileSync(join(configHome, "gentle-shell-bridge", `${TOOL_DESIGN_STORE_ID}.json`), "utf8"),
383
- );
384
- assert.deepEqual(stored.values.selections, {
385
- bash: "loon.bash-heading",
386
- write: "loon.write-badge",
387
- });
388
-
389
- assert.equal(resolveToolDesignSelection("bash").kind, "design");
390
- setToolDesignSelection("bash", "loon.disposed");
391
- assert.deepEqual(resolveToolDesignSelection("bash"), { kind: "missing", id: "loon.disposed" });
392
- });
393
-
394
- // ---------------------------------------------------------------------------------------------
395
- // T8 — the ONE resolver: host renderers, untouched unless a design is selected
396
- // ---------------------------------------------------------------------------------------------
397
-
398
- test("T8: with no design registered the host's renderers come back as the SAME object", () => {
399
- const { renderers } = bashRenderers();
400
-
401
- const resolved = toolDesignResolver("bash", () => renderers);
402
-
403
- assert.equal(resolved, renderers, "identity: returning next() composes, it does not replace");
404
- });
405
-
406
- test("T8/S2: a registered design that is NOT selected draws the host's bytes through a live wrapper", () => {
407
- registerToolDesign(bashHeadingDesign);
408
- const { renderers } = bashRenderers();
409
-
410
- const resolved = toolDesignResolver("bash", () => renderers);
411
- assert.ok(resolved !== undefined);
412
-
413
- // NOT the same object, and that is the point. Returning `next()` itself while nothing is selected
414
- // was the wrong contract: Pi resolves ONCE per card and keeps that component, so a card built
415
- // with the host's own object has no wrapper to ask on a later frame — a design selected afterwards
416
- // could never repaint it, which is exactly the defect the user reported ("cambiar tanto las
417
- // antiguas como posteriores"). The identity that must hold is the identity of the OUTPUT: same
418
- // rows, same bytes, drawn by the host's own component.
419
- assert.notEqual(resolved, renderers, "a tool with a design is always wrapped, selected or not");
420
- assert.equal(resolved.renderShell, "default", "every untouched member is the host's own value");
421
- assert.equal(typeof resolved.renderResult, "function", "both renderers are carried over");
422
-
423
- assert.deepEqual(
424
- renderThrough(resolved, "renderCall", 80).lines,
425
- BASH_CARD,
426
- "no selection: the host's rows, byte for byte, ANSI and trailing padding included",
427
- );
428
- });
429
-
430
- test("S2: a design selected AFTER the card was drawn repaints THAT card, and deselecting restores it", () => {
431
- // The design is registered before the card exists (the resolver must already know that this tool
432
- // has a design), and NOTHING is selected: the card is built in the state the user starts in.
433
- registerToolDesign(bashHeadingDesign);
434
- const { renderers } = bashRenderers();
435
- const resolved = toolDesignResolver("bash", () => renderers);
436
- assert.ok(resolved !== undefined);
437
-
438
- // Pi's one resolution per card: this object is what every later frame re-renders.
439
- const card = mountCard(resolved);
440
-
441
- assert.deepEqual(card.render(80), BASH_CARD, "built with no design selected: the host's own bytes");
442
-
443
- // The user picks the design on the LIVE bus, after the card was already drawn.
444
- setToolDesignSelection("bash", "loon.bash-heading");
445
- assert.deepEqual(
446
- card.render(80),
447
- DESIGNED_BASH_CARD,
448
- "the same, already-constructed component now shows the selected design",
449
- );
450
-
451
- // And back: deselecting repaints the same component with the host's bytes, not a fresh card's.
452
- setToolDesignSelection("bash", TOOL_DESIGN_NATIVE);
453
- assert.deepEqual(card.render(80), BASH_CARD, "deselecting brings the host's own rows back");
454
- });
455
-
456
- test("S2: a design selected after the card was drawn applies to the RESULT renderer too", () => {
457
- registerToolDesign({
458
- tool: "bash",
459
- id: "loon.bash-result",
460
- label: "bash result",
461
- apply: (lines: readonly string[]) => lines.map((line) => line.replace("done", "bash done")),
462
- });
463
- const { renderers } = bashRenderers();
464
- const resolved = toolDesignResolver("bash", () => renderers);
465
- assert.ok(resolved !== undefined);
466
- const card = mountCard(resolved, "renderResult");
467
-
468
- assert.deepEqual(card.render(60), ["╭─ ✿ bash ─╮", "│ done", "╰──────────╯"]);
469
- setToolDesignSelection("bash", "loon.bash-result");
470
- assert.deepEqual(card.render(60), ["╭─ ✿ bash ─╮", "│ bash done", "╰──────────╯"]);
471
- });
472
-
473
- test("T8: an unknown tool name is untouched, and no next() means no renderers", () => {
474
- registerToolDesign(bashHeadingDesign);
475
- setToolDesignSelection("bash", "loon.bash-heading");
476
- const { renderers } = writeRenderers();
477
-
478
- assert.equal(toolDesignResolver("todo", () => renderers), renderers, "an unknown tool is untouched");
479
- assert.equal(toolDesignResolver("bash", () => undefined), undefined, "no host renderers, nothing to wrap");
480
- });
481
-
482
- test("T8: a selected design wraps the host's components and changes only the rows it rewrites", () => {
483
- registerToolDesign(bashHeadingDesign);
484
- setToolDesignSelection("bash", "loon.bash-heading");
485
- const { renderers } = bashRenderers();
486
-
487
- const resolved = toolDesignResolver("bash", () => renderers);
488
- assert.ok(resolved !== undefined);
489
- assert.notEqual(resolved, renderers, "a selected design returns a wrapper, never the host object");
490
- assert.equal(resolved.renderShell, "default", "every other renderer member is carried over");
491
-
492
- const call = renderThrough(resolved, "renderCall", 80);
493
- // Every byte the design did not deliberately change is the host's own: the same rows, the same
494
- // ANSI sequences, the same trailing padding — only `$ ` became `bash ` on the heading row.
495
- const [beforeHeading, afterHeading] = BASH_CARD[1].split("$ ");
496
- assert.deepEqual(call.lines, [
497
- BASH_CARD[0],
498
- `${beforeHeading}bash ${afterHeading}`,
499
- BASH_CARD[2],
500
- BASH_CARD[3],
501
- ]);
502
- for (const [index, line] of call.lines.entries()) {
503
- if (index === 1) continue;
504
- assert.equal(line, BASH_CARD[index], `row ${index} must be byte-identical`);
505
- }
506
- assert.ok(call.lines[1].includes("\u001b[36m"), "ANSI sequences survive the design");
507
- assert.ok(call.lines[1].endsWith(afterHeading), "and so does the host's trailing padding");
508
-
509
- const result = renderThrough(resolved, "renderResult", 60);
510
- assert.equal(result.lines.length, 3, "the result renderer is wrapped the same way");
511
- });
512
-
513
- test("T8: a selection nobody owns draws the host's card, and the next pass picks a live design up", () => {
514
- registerToolDesign(bashHeadingDesign);
515
- setToolDesignSelection("bash", "loon.disposed");
516
- const { renderers } = bashRenderers();
517
-
518
- const resolved = toolDesignResolver("bash", () => renderers);
519
- assert.ok(resolved !== undefined);
520
- assert.deepEqual(resolveToolDesignSelection("bash"), { kind: "missing", id: "loon.disposed" });
521
-
522
- const card = mountCard(resolved);
523
- assert.deepEqual(card.render(80), BASH_CARD, "a disposed design degrades to the host's own rows");
524
-
525
- setToolDesignSelection("bash", "loon.bash-heading");
526
- assert.deepEqual(
527
- card.render(80),
528
- DESIGNED_BASH_CARD,
529
- "the SAME card follows the live selection on its next pass",
530
- );
531
- });
532
-
533
- test("T8: a design for another tool applies to that tool only", () => {
534
- registerToolDesign(bashHeadingDesign);
535
- registerToolDesign(writeBadgeDesign);
536
- setToolDesignSelection("write", "loon.write-badge");
537
-
538
- const write = writeRenderers();
539
- const bash = bashRenderers();
540
- const resolvedWrite = toolDesignResolver("write", () => write.renderers);
541
- const resolvedBash = toolDesignResolver("bash", () => bash.renderers);
542
-
543
- assert.ok(resolvedWrite !== undefined && resolvedWrite !== write.renderers);
544
- assert.ok(resolvedBash !== undefined);
545
- assert.notEqual(resolvedBash, bash.renderers, "bash has designs, so its card is wrapped too");
546
- assert.deepEqual(
547
- renderThrough(resolvedBash, "renderCall", 80).lines,
548
- BASH_CARD,
549
- "but with no selection it draws the host's own bytes, and never write's design",
550
- );
551
- assert.deepEqual(renderThrough(resolvedWrite, "renderCall", 80).lines, [
552
- "╭─ ✿ write ─╮",
553
- "│ WRITE src/index.ts",
554
- "╰───────────╯",
555
- ]);
556
- });
557
-
558
- test("T8/T20: the design receives the width the host asked for, its tool, the theme and the part", () => {
559
- const seen: ToolDesignContext[] = [];
560
- registerToolDesign({
561
- ...bashHeadingDesign,
562
- apply: (lines: readonly string[], ctx: ToolDesignContext): string[] => {
563
- seen.push(ctx);
564
- return [...lines];
565
- },
566
- });
567
- setToolDesignSelection("bash", "loon.bash-heading");
568
- const { renderers } = bashRenderers();
569
- const resolved = toolDesignResolver("bash", () => renderers);
570
- assert.ok(resolved !== undefined);
571
-
572
- renderThrough(resolved, "renderCall", 72);
573
- renderThrough(resolved, "renderResult", 41);
574
-
575
- // The two renderers are wrapped SEPARATELY and each says which half of the card it draws (S12): the
576
- // host draws both with the same chrome, so the rows alone cannot tell a heading from output.
577
- assert.deepEqual(seen[0], { width: 72, tool: "bash", theme: THEME, part: "call" });
578
- assert.deepEqual(
579
- seen[1],
580
- { width: 41, tool: "bash", theme: THEME, part: "result" },
581
- "the theme is the host's own, and the part is the renderer's own",
582
- );
583
- });
584
-
585
- test("T20/S12: a design that ignores `part` draws exactly the rows it drew before the field existed", () => {
586
- // Additive means additive: a design written against the older context keeps its own behaviour, and
587
- // the field it never reads changes nothing about what reaches the screen.
588
- const partBlind: ToolDesignRegistration = {
589
- tool: "bash",
590
- id: "loon.part-blind",
591
- label: "part blind",
592
- apply: (lines: readonly string[]): string[] => lines.map((line) => line.replace("bash", "BASH")),
593
- };
594
- registerToolDesign(partBlind);
595
- setToolDesignSelection("bash", "loon.part-blind");
596
- const { renderers } = bashRenderers();
597
- const resolved = toolDesignResolver("bash", () => renderers);
598
- assert.ok(resolved !== undefined);
599
-
600
- // The same rewrite, byte for byte, in both renderers: the call's own rows and the result's own rows,
601
- // edited by the one rule the design states and nothing else.
602
- assert.deepEqual(renderThrough(resolved, "renderCall", 80).lines, [
603
- "╭─ ✿ BASH card ───────────── ctrl+o ╮",
604
- "│ \u001b[36m$ ls -la\u001b[0m ",
605
- "│ total 4 ",
606
- "╰───────────────────────────────────╯",
607
- ]);
608
- assert.deepEqual(renderThrough(resolved, "renderResult", 60).lines, [
609
- "╭─ ✿ BASH ─╮",
610
- "│ done",
611
- "╰──────────╯",
612
- ]);
613
- });
614
-
615
- test("T8: an apply that throws leaves the host's card intact and is reported ONCE", () => {
616
- const failing: ToolDesignRegistration = {
617
- ...bashHeadingDesign,
618
- id: "loon.thrower",
619
- apply: () => {
620
- throw new Error("design exploded");
621
- },
622
- };
623
- registerToolDesign(failing);
624
- setToolDesignSelection("bash", "loon.thrower");
625
- const { renderers } = bashRenderers();
626
- const resolved = toolDesignResolver("bash", () => renderers);
627
- assert.ok(resolved !== undefined);
628
-
629
- assert.deepEqual(renderThrough(resolved, "renderCall", 80).lines, BASH_CARD);
630
- assert.deepEqual(renderThrough(resolved, "renderCall", 80).lines, BASH_CARD, "every frame stays whole");
631
-
632
- const failures = getToolDesignFailures();
633
- assert.equal(failures.length, 1, "one fact, not one per frame");
634
- assert.equal(failures[0].tool, "bash");
635
- assert.equal(failures[0].designId, "loon.thrower");
636
- assert.match(failures[0].message, /design exploded/u);
637
- });
638
-
639
- test("T8: an apply that returns nonsense is a failure, never a broken card", () => {
640
- registerToolDesign({
641
- ...bashHeadingDesign,
642
- id: "loon.nonsense",
643
- apply: (() => [42]) as unknown as ToolDesignRegistration["apply"],
644
- });
645
- setToolDesignSelection("bash", "loon.nonsense");
646
- const { renderers } = bashRenderers();
647
- const resolved = toolDesignResolver("bash", () => renderers);
648
- assert.ok(resolved !== undefined);
649
-
650
- assert.deepEqual(renderThrough(resolved, "renderCall", 80).lines, BASH_CARD);
651
- assert.equal(getToolDesignFailures().length, 1);
652
- });
653
-
654
- // ---------------------------------------------------------------------------------------------
655
- // T8 — one registration point, at the same moment the bridge arms its other surfaces
656
- // ---------------------------------------------------------------------------------------------
657
-
658
- interface FakePi {
659
- pi: BridgeExtensionApi;
660
- resolvers: unknown[];
661
- fireSessionStart: () => void;
662
- }
663
-
664
- function fakePi(): FakePi {
665
- const resolvers: unknown[] = [];
666
- let sessionStart: (() => unknown) | undefined;
667
- const pi: BridgeExtensionApi = {
668
- // `gentle-pi` is present, so `session_start` installs the resolver (S9/T12).
669
- getCommands: () => [{ name: "gentle:customize" }],
670
- on(event, handler) {
671
- if (event === "session_start") sessionStart = () => handler({}, { hasUI: true, ui: createFakeHost().ui });
672
- },
673
- registerCommand() {},
674
- registerToolRenderer(resolver) {
675
- resolvers.push(resolver);
676
- },
677
- };
678
- return {
679
- pi,
680
- resolvers,
681
- fireSessionStart: () => {
682
- sessionStart?.();
683
- },
684
- };
685
- }
686
-
687
- test("T8: the bridge registers exactly one resolver, and re-arming registers none", () => {
688
- const fake = fakePi();
689
-
690
- gentleShellBridge(fake.pi);
691
- fake.fireSessionStart();
692
-
693
- assert.equal(fake.resolvers.length, 1, "ONE resolution point for the whole package");
694
- assert.equal(fake.resolvers[0], toolDesignResolver);
695
-
696
- fake.fireSessionStart();
697
- installToolDesignResolver(fake.pi);
698
- assert.equal(fake.resolvers.length, 1, "a rebind or a second arm adds no second resolver");
699
- });
700
-
701
- test("T8: a second loaded copy shares the slot and does not register a second resolver", async () => {
702
- const fake = fakePi();
703
- gentleShellBridge(fake.pi);
704
- fake.fireSessionStart();
705
- assert.equal(fake.resolvers.length, 1);
706
-
707
- // Pi loads every extension with `moduleCache: false`, so this module genuinely exists twice.
708
- const copy = (await import(new URL("../src/tool-design-wiring.ts?second-copy", import.meta.url).href)) as
709
- typeof import("../src/tool-design-wiring.ts");
710
- const result = copy.installToolDesignResolver(fake.pi);
711
-
712
- assert.equal(result.outcome, "already-registered");
713
- assert.equal(fake.resolvers.length, 1, "the process-global slot is the idempotency key");
714
- });
715
-
716
- test("T8: a host without registerToolRenderer is a reported outcome, never a throw", () => {
717
- const result = installToolDesignResolver({ on() {}, registerCommand() {} });
718
- assert.equal(result.installed, false);
719
- assert.equal(result.outcome, "no-register-function");
720
- });
721
-
722
- test("PRESERVE: arming without a UI registers nothing, exactly as before", () => {
723
- const resolvers: unknown[] = [];
724
- let sessionStart: ((event: unknown, ctx: unknown) => unknown) | undefined;
725
- const pi: BridgeExtensionApi = {
726
- on(event, handler) {
727
- if (event === "session_start") sessionStart = handler as (event: unknown, ctx: unknown) => unknown;
728
- },
729
- registerCommand() {},
730
- registerToolRenderer(resolver) {
731
- resolvers.push(resolver);
732
- },
733
- };
734
- gentleShellBridge(pi);
735
- sessionStart?.({}, { hasUI: false });
736
-
737
- assert.deepEqual(resolvers, []);
738
- });
739
-
740
- test("PRESERVE: armBridge still reports its own outcome with designs registered", () => {
741
- registerToolDesign(bashHeadingDesign);
742
- const host = createFakeHost();
743
- const result = armBridge(host.ui);
744
- assert.notEqual(result.backend, "none", "the design bus never changes the row interception");
745
- });