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,473 +0,0 @@
1
- /**
2
- * S13/T23 — the WORK a design switch does, counted instead of timed.
3
- *
4
- * Wall-clock numbers measured on real pi-tui and pi's `ToolExecutionComponent` (T22) say the freeze
5
- * came from two calls the host's own card-style switch never makes:
6
- *
7
- * - `tui.invalidate()` clears every mounted component's frame cache, so the next frame re-parses
8
- * every Markdown message of the transcript (0.06 ms at 50 children, 1.1 ms at 600 for the walk
9
- * alone, and +709 ms for the frame that re-parses 300 messages with 45-line code blocks);
10
- * - `requestRender(force = true)` calls `resetRenderState()`, throwing away the differential state so
11
- * the whole buffer is cleared and rewritten (6.9 MB at 600 children) instead of only the rows that
12
- * changed.
13
- *
14
- * Neither is needed to change a card that is already drawn: the bridge's wrapper renders the HOST's
15
- * component and applies the live selection on top of whatever rows it answered with — including the
16
- * rows the host's own `ToolCardRows` cache hands back (`cards-bash-v2` `extensions/quiet-tools.ts`
17
- * 612-624 keys its frame on width + style + running, and the design is NOT in that key). This file
18
- * proves that against a host component that caches exactly like that one, and bounds the switch:
19
- *
20
- * - the tree is never invalidated, and no prose component re-parses;
21
- * - exactly one frame is requested, and it is differential;
22
- * - at most one persisted-selection read per card per frame;
23
- * - none of those counts grows with the number of Markdown components in the transcript.
24
- *
25
- * The bound is call counts, never milliseconds, so it cannot flake on a busy machine.
26
- */
27
-
28
- import assert from "node:assert/strict";
29
- import { mkdtempSync, rmSync } from "node:fs";
30
- import { tmpdir } from "node:os";
31
- import { join } from "node:path";
32
- import test from "node:test";
33
-
34
- import { resetNoticesForTests } from "../src/notices.ts";
35
- import { resetPluginLinesForTests } from "../src/plugin-lines.ts";
36
- import {
37
- getRepaintDiagnostics,
38
- requestSurfaceRepaint,
39
- resetRepaintForTests,
40
- setRepaintHost,
41
- } from "../src/repaint.ts";
42
- import { buildRows, resetRowBuildStateForTests } from "../src/rows.ts";
43
- import { resetStoreStateForTests } from "../src/store.ts";
44
- import {
45
- TOOL_DESIGN_NATIVE,
46
- getToolDesignSelectionReads,
47
- registerToolDesign,
48
- resetToolDesignSelectionReads,
49
- resetToolDesignsForTests,
50
- setToolDesignSelection,
51
- } from "../src/tool-designs.ts";
52
- import {
53
- installToolDesignRows,
54
- resetToolDesignRowStateForTests,
55
- } from "../src/tool-design-rows.ts";
56
- import { resetToolDesignWiringForTests, toolDesignResolver } from "../src/tool-design-wiring.ts";
57
- import type { CustomizeRow, ToolDesignContext, ToolDesignRegistration } from "../src/types.ts";
58
-
59
- let configHome: string;
60
- let previousConfigHome: string | undefined;
61
-
62
- test.before(() => {
63
- configHome = mkdtempSync(join(tmpdir(), "gsb-tool-design-cost-"));
64
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
65
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
66
- });
67
-
68
- test.after(() => {
69
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
70
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
71
- rmSync(configHome, { recursive: true, force: true });
72
- });
73
-
74
- test.beforeEach(() => {
75
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
76
- resetToolDesignsForTests();
77
- resetToolDesignRowStateForTests();
78
- resetToolDesignWiringForTests();
79
- resetRepaintForTests();
80
- resetStoreStateForTests();
81
- resetNoticesForTests();
82
- resetPluginLinesForTests();
83
- resetRowBuildStateForTests();
84
- });
85
-
86
- const WIDTH = 80;
87
- const THEME = { name: "fake-theme" };
88
- const BASH_HEADING = "bash heading";
89
-
90
- /** The host's own rows for one bash card, as `quiet-tools.ts` draws them. */
91
- const HOST_CARD = [
92
- "╭─ ✿ bash card ───────────── ctrl+o ╮",
93
- "│ \u001b[36m$ ls -la\u001b[0m │",
94
- "╰───────────────────────────────────╯",
95
- ];
96
-
97
- const DESIGNED_CARD = [
98
- HOST_CARD[0],
99
- HOST_CARD[1].replace("$ ", "bash "),
100
- HOST_CARD[2],
101
- ];
102
-
103
- /** How many times the design applied since the last reset (one per card per frame while selected). */
104
- let applied = 0;
105
-
106
- const bashHeadingDesign: ToolDesignRegistration = {
107
- tool: "bash",
108
- id: "loon.bash-heading",
109
- label: BASH_HEADING,
110
- apply(lines: readonly string[], ctx: ToolDesignContext): string[] {
111
- applied += 1;
112
- assert.equal(ctx.tool, "bash");
113
- return lines.map((line) => line.replace("$ ", "bash "));
114
- },
115
- };
116
-
117
- // ---------------------------------------------------------------------------------------------
118
- // The instrumented host: a frame counter on every component, exactly like pi's TUI
119
- // ---------------------------------------------------------------------------------------------
120
-
121
- interface Counters {
122
- /** `tui.invalidate()`: the call that cleared every cache in the tree. */
123
- treeInvalidations: number;
124
- /** What each `requestRender()` was called with: `plain` or `forced`. */
125
- frameRequests: Array<"plain" | "forced">;
126
- /** A prose row re-parsed its source because its cache was dropped. */
127
- proseCacheMisses: number;
128
- /** A prose row had its cache dropped. */
129
- proseInvalidations: number;
130
- /** The host card's renderer ran (it may answer from its own cache). */
131
- hostRenders: number;
132
- /** The host card answered from its own frame cache (the rows the design must post-process). */
133
- hostCacheHits: number;
134
- /** The host card rebuilt its rows (width/style/running changed). */
135
- hostRebuilds: number;
136
- /** Cards whose `invalidate()` the bridge called. */
137
- cardInvalidations: number;
138
- /** Design applications. */
139
- designApplies: number;
140
- }
141
-
142
- /** A `Markdown`-shaped row: it caches its lines and re-parses only after `invalidate()`. */
143
- function mountProse(text: string, counters: Counters): { render(width: number): string[]; invalidate(): void } {
144
- let cached: string[] | undefined;
145
- return {
146
- render(width: number): string[] {
147
- if (cached !== undefined) return cached;
148
- counters.proseCacheMisses += 1;
149
- cached = [`${text} `.repeat(4).trim().padEnd(width, " ")];
150
- return cached;
151
- },
152
- invalidate(): void {
153
- counters.proseInvalidations += 1;
154
- cached = undefined;
155
- },
156
- };
157
- }
158
-
159
- /**
160
- * A host bash card whose renderer caches its frame the way `ToolCardRows` does
161
- * (`extensions/quiet-tools.ts:612-624`): keyed on width, the host's own style and whether it is
162
- * running. The DESIGN is not in that key — which is why the rows it answers with are cached under
163
- * the previous selection, and why the wrapper must post-process them instead of the tree being
164
- * invalidated.
165
- */
166
- function hostCardDefinition(counters: Counters): Record<string, unknown> {
167
- const frame = { width: -1, style: "", lines: [...HOST_CARD] };
168
- const hostStyle = "float";
169
- return {
170
- renderShell: "default",
171
- renderCall: (..._args: unknown[]) => ({
172
- render(width: number): string[] {
173
- counters.hostRenders += 1;
174
- if (frame.width === width && frame.style === hostStyle) {
175
- counters.hostCacheHits += 1;
176
- return frame.lines;
177
- }
178
- counters.hostRebuilds += 1;
179
- frame.width = width;
180
- frame.style = hostStyle;
181
- frame.lines = [...HOST_CARD];
182
- return frame.lines;
183
- },
184
- invalidate(): void {
185
- frame.width = -1;
186
- },
187
- }),
188
- };
189
- }
190
-
191
- /** The part of pi's `ToolExecutionComponent` this test needs: it stores and re-invokes the renderer. */
192
- interface FakeCard {
193
- toolName: string;
194
- toolDefinition: Record<string, unknown>;
195
- renders: number;
196
- component?: { render(width: number): string[] };
197
- updateDisplay(): void;
198
- invalidate(): void;
199
- render(width: number): string[];
200
- }
201
-
202
- function mountCard(counters: Counters): FakeCard {
203
- const card: FakeCard = {
204
- toolName: "bash",
205
- toolDefinition: toolDesignResolver("bash", () => hostCardDefinition(counters)) as Record<
206
- string,
207
- unknown
208
- >,
209
- renders: 0,
210
- updateDisplay(): void {
211
- const renderCall = card.toolDefinition.renderCall;
212
- card.component =
213
- typeof renderCall === "function"
214
- ? (renderCall as (...args: unknown[]) => { render(width: number): string[] })(
215
- { command: "ls -la" },
216
- THEME,
217
- { args: {} },
218
- )
219
- : undefined;
220
- },
221
- invalidate(): void {
222
- counters.cardInvalidations += 1;
223
- card.updateDisplay();
224
- },
225
- render(width: number): string[] {
226
- card.renders += 1;
227
- return card.component === undefined ? [] : card.component.render(width);
228
- },
229
- };
230
- card.updateDisplay();
231
- return card;
232
- }
233
-
234
- interface Transcript {
235
- counters: Counters;
236
- /** The conversation render pass: every mounted child, in order (pi's `Container.render`). */
237
- drawFrame(): string[][];
238
- cards: FakeCard[];
239
- proseInvalidations: () => number;
240
- proseCacheMisses: () => number;
241
- }
242
-
243
- function transcript(options: { children: number; markdown: number }): Transcript {
244
- const counters: Counters = {
245
- treeInvalidations: 0,
246
- frameRequests: [],
247
- proseCacheMisses: 0,
248
- proseInvalidations: 0,
249
- hostRenders: 0,
250
- hostCacheHits: 0,
251
- hostRebuilds: 0,
252
- cardInvalidations: 0,
253
- designApplies: 0,
254
- // The count of design applications is kept by the registered design itself; see `press`.
255
- } as Counters;
256
-
257
- const nodes: Array<{ render(width: number): string[]; invalidate?(): void }> = [];
258
- const cards: FakeCard[] = [];
259
- const bash = options.children - options.markdown;
260
- for (let index = 0; index < options.markdown; index += 1) {
261
- nodes.push(mountProse(`assistant message ${index}`, counters));
262
- }
263
- for (let index = 0; index < bash; index += 1) {
264
- const card = mountCard(counters);
265
- cards.push(card);
266
- nodes.push(card);
267
- }
268
-
269
- setRepaintHost({
270
- terminal: {},
271
- invalidate: (): void => {
272
- counters.treeInvalidations += 1;
273
- for (const node of nodes) node.invalidate?.();
274
- },
275
- requestRender: (force?: boolean): void => {
276
- counters.frameRequests.push(force === true ? "forced" : "plain");
277
- },
278
- });
279
-
280
- return {
281
- counters,
282
- drawFrame: () => nodes.map((node) => node.render(WIDTH)),
283
- cards,
284
- proseInvalidations: () => counters.proseInvalidations,
285
- proseCacheMisses: () => counters.proseCacheMisses,
286
- };
287
- }
288
-
289
- /** The menu's live rows, built through the host contract the menu reads. */
290
- function rows(): CustomizeRow[] {
291
- installToolDesignRows();
292
- return buildRows({ theme: undefined, configHome }).rows;
293
- }
294
-
295
- /** Press one value of the bash line, exactly as the menu runs a row action. */
296
- async function press(value: string): Promise<void> {
297
- const row = rows().find((candidate) => candidate.choice?.value === value);
298
- assert.ok(row?.action !== undefined, `the menu has a ${JSON.stringify(value)} value`);
299
- await row.action({
300
- input: async () => undefined,
301
- confirm: async () => true,
302
- capture: async () => undefined,
303
- });
304
- }
305
-
306
- /** Everything one switch costs, as counts. */
307
- interface SwitchCost {
308
- treeInvalidations: number;
309
- frameRequests: Array<"plain" | "forced">;
310
- selectionReadsInFrame: number;
311
- designAppliesInFrame: number;
312
- proseCacheMissesInFrame: number;
313
- hostCacheHitsInFrame: number;
314
- cardInvalidations: number;
315
- }
316
-
317
- async function measureSwitch(options: { children: number; markdown: number }): Promise<{
318
- cost: SwitchCost;
319
- drawn: string[][];
320
- transcript: Transcript;
321
- }> {
322
- registerToolDesign(bashHeadingDesign);
323
- setToolDesignSelection("bash", TOOL_DESIGN_NATIVE);
324
- const world = transcript(options);
325
- // Warm every cache with one frame of the host's own rows.
326
- world.drawFrame();
327
-
328
- await press(BASH_HEADING);
329
-
330
- resetToolDesignSelectionReads();
331
- const appliesBefore = applied;
332
- const proseBefore = world.proseCacheMisses();
333
- const hitsBefore = world.counters.hostCacheHits;
334
- const drawn = world.drawFrame();
335
-
336
- return {
337
- cost: {
338
- treeInvalidations: world.counters.treeInvalidations,
339
- frameRequests: [...world.counters.frameRequests],
340
- selectionReadsInFrame: getToolDesignSelectionReads(),
341
- designAppliesInFrame: applied - appliesBefore,
342
- proseCacheMissesInFrame: world.proseCacheMisses() - proseBefore,
343
- hostCacheHitsInFrame: world.counters.hostCacheHits - hitsBefore,
344
- cardInvalidations: world.counters.cardInvalidations,
345
- },
346
- drawn,
347
- transcript: world,
348
- };
349
- }
350
-
351
- // ---------------------------------------------------------------------------------------------
352
- // T23 — the bound
353
- // ---------------------------------------------------------------------------------------------
354
-
355
- test("T23/S13: a switch asks for ONE differential frame and invalidates nothing", async () => {
356
- registerToolDesign(bashHeadingDesign);
357
- const world = transcript({ children: 6, markdown: 3 });
358
- world.drawFrame();
359
-
360
- await press(BASH_HEADING);
361
-
362
- assert.equal(
363
- world.counters.treeInvalidations,
364
- 0,
365
- "the tree is not invalidated: every Markdown row would re-parse and every card would rebuild",
366
- );
367
- assert.deepEqual(
368
- world.counters.frameRequests,
369
- ["plain"],
370
- "one frame, differential: no forced full redraw of the whole buffer",
371
- );
372
- assert.equal(world.counters.cardInvalidations, 0, "no card is invalidated by a selection change");
373
-
374
- const drawn = world.drawFrame();
375
- assert.deepEqual(drawn.filter((lines) => lines.length === HOST_CARD.length), [
376
- DESIGNED_CARD,
377
- DESIGNED_CARD,
378
- DESIGNED_CARD,
379
- ], "every already-constructed card draws the design");
380
- assert.equal(
381
- world.counters.hostCacheHits,
382
- 3,
383
- "the host answered from its OWN frame cache; the wrapper post-processed those rows",
384
- );
385
- assert.equal(world.counters.hostRebuilds, 3, "the host rebuilt its rows once, at warm-up");
386
- assert.equal(getRepaintDiagnostics().lastTrigger?.outcome, "repainted");
387
-
388
- // S2's other half: `native` restores the host's exact bytes on the SAME components.
389
- await press(TOOL_DESIGN_NATIVE);
390
- assert.deepEqual(
391
- world.drawFrame().filter((lines) => lines.length === HOST_CARD.length),
392
- [HOST_CARD, HOST_CARD, HOST_CARD],
393
- "native restores the host's own rows, byte for byte",
394
- );
395
- assert.equal(world.counters.treeInvalidations, 0, "the way back costs no invalidation either");
396
- assert.deepEqual(world.counters.frameRequests, ["plain", "plain"], "one frame per press");
397
- });
398
-
399
- test("T23/S13: the work of a switch does not grow with the number of Markdown rows", async () => {
400
- const small = await measureSwitch({ children: 6, markdown: 3 });
401
- await press(TOOL_DESIGN_NATIVE);
402
- const large = await measureSwitch({ children: 203, markdown: 200 });
403
-
404
- assert.deepEqual(
405
- large.cost,
406
- {
407
- ...small.cost,
408
- // The host renders once per card per frame in both; that is the switch's real work, and it is
409
- // per CARD, never per Markdown row. Nothing else may differ.
410
- },
411
- "same cards, same frame, same reads: 200 prose rows cost a switch exactly what 3 do",
412
- );
413
- assert.equal(large.cost.treeInvalidations, 0);
414
- assert.deepEqual(large.cost.frameRequests, ["plain"]);
415
- });
416
-
417
- test("T23/S13: a switch performs at most one selection read per card per frame", async () => {
418
- registerToolDesign(bashHeadingDesign);
419
- const world = transcript({ children: 10, markdown: 6 });
420
- world.drawFrame();
421
-
422
- await press(BASH_HEADING);
423
-
424
- resetToolDesignSelectionReads();
425
- world.drawFrame();
426
- const firstFrame = getToolDesignSelectionReads();
427
- assert.equal(firstFrame, 4, "one read per card, no more");
428
-
429
- resetToolDesignSelectionReads();
430
- world.drawFrame();
431
- assert.equal(getToolDesignSelectionReads(), 4, "and the same on every later frame");
432
-
433
- // A frame with no design still reads the selection once per card: the bound is about the CARD,
434
- // not about which value is selected.
435
- await press(TOOL_DESIGN_NATIVE);
436
- resetToolDesignSelectionReads();
437
- world.drawFrame();
438
- assert.equal(getToolDesignSelectionReads(), 4);
439
-
440
- // Reads never grow with the transcript's prose, only with its cards.
441
- const wide = await measureSwitch({ children: 104, markdown: 100 });
442
- assert.equal(wide.cost.selectionReadsInFrame, 4, "100 prose rows still cost four selection reads");
443
- assert.ok(
444
- wide.cost.selectionReadsInFrame <= 4,
445
- "the bound: at most one read per card per frame",
446
- );
447
- });
448
-
449
- test("T23/S13: the prose rows never re-parse for a switch", async () => {
450
- const { cost } = await measureSwitch({ children: 30, markdown: 25 });
451
- assert.equal(cost.proseCacheMissesInFrame, 0, "no Markdown row re-parses on a design switch");
452
- assert.equal(cost.cardInvalidations, 0);
453
- });
454
-
455
- // S19 replaced the PRESERVE test that pinned the heavy path (`tui.invalidate()` + a forced frame) for
456
- // bridge-owned rows. Measured with real pi components, the rows are byte-identical without them on the
457
- // conversation, the rail's host panels and the bridge's sections, and the invalidation was the
458
- // expensive half (697 ms vs 420 ms for `plain`, 99 ms vs 3 ms for a rail section toggle at 600
459
- // children). test/surface-repaint-cost.test.ts holds the full bound; this pins the same request here.
460
- test("T36/S19: the bridge-owned surface repaint is one differential frame too", () => {
461
- const world = transcript({ children: 2, markdown: 1 });
462
- world.counters.treeInvalidations = 0;
463
- world.counters.frameRequests.length = 0;
464
-
465
- requestSurfaceRepaint({ cause: "card style → neon", surfaces: ["conversation", "rail"] });
466
-
467
- assert.equal(world.counters.treeInvalidations, 0, "no tree invalidation: the Markdown caches survive");
468
- assert.deepEqual(world.counters.frameRequests, ["plain"], "one differential frame, not a forced one");
469
- });
470
-
471
- // Keep the compiler honest about the design type used above.
472
- const _contextProbe: ToolDesignContext = { width: WIDTH, tool: "bash", theme: THEME, part: "call" };
473
- void _contextProbe;