gentle-shell-bridge 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/docs/contract.md +1 -1
  3. package/package.json +13 -1
  4. package/src/api.ts +1 -1
  5. package/.codegraph/.gitignore +0 -5
  6. package/PRD.md +0 -144
  7. package/examples/bash-card.ts +0 -1694
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +0 -30
  10. package/odd/tasks/plugin-keymap.md +0 -32
  11. package/odd/tasks/plugin-surface-contract.md +0 -55
  12. package/test/api.test.ts +0 -395
  13. package/test/apply-on-move.test.ts +0 -477
  14. package/test/arm.test.ts +0 -220
  15. package/test/bridge-ready.test.ts +0 -309
  16. package/test/bus.test.ts +0 -165
  17. package/test/button-rows.test.ts +0 -409
  18. package/test/capture.test.ts +0 -356
  19. package/test/card-style-rows.test.ts +0 -510
  20. package/test/card-style-transform.test.ts +0 -379
  21. package/test/card-style-wiring.test.ts +0 -258
  22. package/test/card-styles-e2e.test.ts +0 -491
  23. package/test/card-styles.test.ts +0 -324
  24. package/test/card-transcript-path.test.ts +0 -178
  25. package/test/config.test.ts +0 -93
  26. package/test/e2e.test.ts +0 -443
  27. package/test/example-bash-identity.test.ts +0 -372
  28. package/test/example-bash-matrix.test.ts +0 -856
  29. package/test/example-designs.test.ts +0 -734
  30. package/test/example-neon-transform.test.ts +0 -283
  31. package/test/example-plain-style.test.ts +0 -1268
  32. package/test/extension.test.ts +0 -160
  33. package/test/fake-events.ts +0 -58
  34. package/test/fake-host.ts +0 -211
  35. package/test/fake-sidebar.ts +0 -516
  36. package/test/fake-transcript.ts +0 -486
  37. package/test/frame-telemetry.test.ts +0 -417
  38. package/test/hook.test.ts +0 -196
  39. package/test/manifest.test.ts +0 -105
  40. package/test/new-category.test.ts +0 -979
  41. package/test/notices.test.ts +0 -328
  42. package/test/plugin-integration-lines.test.ts +0 -979
  43. package/test/plugin-surface.test.ts +0 -152
  44. package/test/presence.test.ts +0 -459
  45. package/test/rail-surfaces.test.ts +0 -457
  46. package/test/rail-width.test.ts +0 -423
  47. package/test/regression.test.ts +0 -483
  48. package/test/repaint-api.test.ts +0 -200
  49. package/test/reserved-categories.test.ts +0 -361
  50. package/test/row-labels.test.ts +0 -284
  51. package/test/row-names.test.ts +0 -473
  52. package/test/row-origin.test.ts +0 -395
  53. package/test/section-interaction.test.ts +0 -364
  54. package/test/section-panel.test.ts +0 -403
  55. package/test/sections-rows.test.ts +0 -291
  56. package/test/sidebar-backend.test.ts +0 -471
  57. package/test/sidebar-e2e.test.ts +0 -331
  58. package/test/sidebar-hover.test.ts +0 -276
  59. package/test/sidebar-independent.test.ts +0 -349
  60. package/test/sidebar-render.test.ts +0 -281
  61. package/test/sidebar-sections.test.ts +0 -245
  62. package/test/status-card-styles.test.ts +0 -201
  63. package/test/status-section-frame.test.ts +0 -181
  64. package/test/status-sections.test.ts +0 -243
  65. package/test/status.test.ts +0 -209
  66. package/test/store.test.ts +0 -192
  67. package/test/surface-repaint-cost.test.ts +0 -520
  68. package/test/tool-design-chain.test.ts +0 -483
  69. package/test/tool-design-cost.test.ts +0 -473
  70. package/test/tool-design-rows.test.ts +0 -359
  71. package/test/tool-designs.test.ts +0 -745
  72. package/test/transform-pipeline.test.ts +0 -465
  73. package/test/transforms-bus.test.ts +0 -233
  74. package/test/verifier-width-regressions.test.ts +0 -164
  75. package/tsconfig.json +0 -18
@@ -1,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;