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
package/test/e2e.test.ts DELETED
@@ -1,443 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import test from "node:test";
6
-
7
- // The public surface, exactly as a plugin reaches it.
8
- import * as publicApi from "../src/index.ts";
9
- import {
10
- createStore,
11
- registerRows,
12
- STORE_SCHEMA_VERSION,
13
- VERSION,
14
- type CardFrame,
15
- type CardFrameBottomContext,
16
- type CardFrameLineContext,
17
- type CardFrameTopContext,
18
- type CardStyleRegistration,
19
- type CustomizeRow,
20
- type InlineHelpers,
21
- type RowChoice,
22
- type RowContext,
23
- type RowLabel,
24
- type RowPreview,
25
- type RowProvider,
26
- type SectionContext,
27
- type SidebarSection,
28
- type Store,
29
- type StoreProblem,
30
- type StoreProblemKind,
31
- type TransformContext,
32
- type TranscriptTransform,
33
- } from "../src/index.ts";
34
- // The real Pi entry: armed and commanded exactly as Pi loads it.
35
- import gentleShellBridge, {
36
- STATUS_COMMAND,
37
- type BridgeCommandHandler,
38
- type BridgeExtensionApi,
39
- type BridgeSessionHandler,
40
- } from "../src/extension.ts";
41
- // Teardown-only seams; the API-surface test proves they are not public.
42
- import { resetBusForTests } from "../src/bus.ts";
43
- import { resetCaptureStateForTests } from "../src/capture.ts";
44
- import { resetHookStateForTests } from "../src/hook.ts";
45
- import { resetRowBuildStateForTests } from "../src/rows.ts";
46
- import { resetStoreStateForTests } from "../src/store.ts";
47
- import { createFakeHost, type FakeHost } from "./fake-host.ts";
48
-
49
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
50
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
51
-
52
- let configHome: string;
53
- let previousConfigHome: string | undefined;
54
- let pristineMap: typeof Array.prototype.map;
55
-
56
- test.before(() => {
57
- pristineMap = Array.prototype.map;
58
- configHome = mkdtempSync(join(tmpdir(), "gsb-e2e-"));
59
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
60
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
61
- });
62
-
63
- test.after(() => {
64
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
65
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
66
- Array.prototype.map = pristineMap;
67
- rmSync(configHome, { recursive: true, force: true });
68
- });
69
-
70
- test.beforeEach(() => {
71
- resetBusForTests();
72
- resetCaptureStateForTests();
73
- resetRowBuildStateForTests();
74
- resetHookStateForTests();
75
- resetStoreStateForTests();
76
- delete hookSlot()[HOOK_KEY];
77
- });
78
-
79
- test.afterEach(() => {
80
- Array.prototype.map = pristineMap;
81
- delete hookSlot()[HOOK_KEY];
82
- });
83
-
84
- interface Entry {
85
- sessionHandlers: BridgeSessionHandler[];
86
- commands: Array<{ name: string; description?: string; handler: BridgeCommandHandler }>;
87
- }
88
-
89
- /** Load the real extension entry against a recording API, as Pi would. */
90
- const loadEntry = (): Entry => {
91
- const sessionHandlers: BridgeSessionHandler[] = [];
92
- const commands: Entry["commands"] = [];
93
- const pi: BridgeExtensionApi = {
94
- // `gentle-pi` is loaded, so the runtime gate is open (S9/T12).
95
- getCommands: () => [{ name: "gentle:customize" }],
96
- on(_event, handler) {
97
- sessionHandlers.push(handler);
98
- },
99
- registerCommand(name, command) {
100
- commands.push({ name, description: command.description, handler: command.handler });
101
- },
102
- };
103
- gentleShellBridge(pi);
104
- return { sessionHandlers, commands };
105
- };
106
-
107
- /** Pi fires `session_start`; the entry arms the bridge. */
108
- const armThroughEntry = (ui: unknown): Entry => {
109
- const entry = loadEntry();
110
- entry.sessionHandlers[0]({}, { hasUI: true, ui });
111
- return entry;
112
- };
113
-
114
- /** Run the registered `status` command and return exactly what it prints. */
115
- const printStatus = (entry: Entry): string => {
116
- const printed: string[] = [];
117
- entry.commands[0].handler("", { ui: { notify: (message: string) => printed.push(message) } });
118
- return printed.join("\n");
119
- };
120
-
121
- const labels = (id: string, category: string, values: string[]): RowProvider => ({
122
- id,
123
- category,
124
- rows: () => values.map((label) => ({ label, action: () => {} })),
125
- });
126
-
127
- const armAndBuild = (host: FakeHost): Entry => {
128
- const entry = armThroughEntry(host.ui);
129
- host.buildMenu();
130
- return entry;
131
- };
132
-
133
- test("two providers reach one menu build through registerRows, in subscription order", () => {
134
- registerRows(labels("first", "Cards", ["First a"]));
135
- registerRows(labels("second", "Banner", ["Second b"]));
136
- const host = createFakeHost();
137
-
138
- armAndBuild(host);
139
-
140
- assert.deepEqual(host.rendered().slice(4), ["First a", "Second b"]);
141
- assert.deepEqual(
142
- host.liveRows().slice(4).map((row) => row.category),
143
- ["Cards", "Banner"],
144
- );
145
- });
146
-
147
- test("a throwing provider loses only its rows, the menu still renders, and status records it", () => {
148
- registerRows(labels("before", "Cards", ["Before ok"]));
149
- registerRows({
150
- id: "boom",
151
- category: "Banner",
152
- rows: () => {
153
- throw new Error("e2e provider exploded");
154
- },
155
- });
156
- registerRows(labels("after", "Layout", ["After ok"]));
157
- const host = createFakeHost();
158
-
159
- const entry = armAndBuild(host);
160
-
161
- assert.deepEqual(host.rendered().slice(4), ["Before ok", "After ok"]);
162
- const status = printStatus(entry);
163
- assert.match(status, /^last failures: 1$/m);
164
- assert.match(status, /boom: e2e provider exploded \(rows dropped\)/);
165
- });
166
-
167
- test("Array.prototype.map identity survives success, a throwing factory and a non-row array", () => {
168
- registerRows(labels("p1", "Cards", ["Row"]));
169
-
170
- const okHost = createFakeHost();
171
- armThroughEntry(okHost.ui);
172
- const beforeSuccess = Array.prototype.map;
173
- okHost.buildMenu();
174
- assert.equal(Array.prototype.map, beforeSuccess);
175
-
176
- const throwingHost = createFakeHost({ throwInsideFactory: true });
177
- armThroughEntry(throwingHost.ui);
178
- const beforeThrow = Array.prototype.map;
179
- assert.throws(() => throwingHost.buildMenu(), /factory exploded/);
180
- assert.equal(Array.prototype.map, beforeThrow);
181
-
182
- const nonRowHost = createFakeHost({ mapOnlyNonRows: true });
183
- armThroughEntry(nonRowHost.ui);
184
- const beforeNonRows = Array.prototype.map;
185
- nonRowHost.buildMenu();
186
- assert.equal(Array.prototype.map, beforeNonRows);
187
- assert.deepEqual(
188
- nonRowHost.rendered().filter((label) => label === "Row"),
189
- [],
190
- "a plain array of numbers is never injected into",
191
- );
192
- });
193
-
194
- test("two session_start events through the entry arm once: one injection, one invalidate", () => {
195
- registerRows(labels("p1", "Cards", ["Once"]));
196
- const host = createFakeHost();
197
- const entry = loadEntry();
198
- const context = { hasUI: true, ui: host.ui };
199
-
200
- entry.sessionHandlers[0]({}, context);
201
- entry.sessionHandlers[0]({}, context);
202
-
203
- host.buildMenu();
204
- assert.deepEqual(
205
- host.rendered().filter((label) => label === "Once"),
206
- ["Once"],
207
- "the rows are injected once, not twice",
208
- );
209
- assert.equal(host.invalidateCount(), 1, "one invalidate per menu build");
210
- assert.equal(host.factoryCallCount(), 1, "the host factory ran once");
211
- assert.match(printStatus(entry), /^backend: capture$/m);
212
- });
213
-
214
- test("the hook registry is preferred through the entry: nothing wrapped, rows via the registry", () => {
215
- const registry: Array<{ id: string; rows: (ctx?: unknown) => CustomizeRow[] }> = [];
216
- hookSlot()[HOOK_KEY] = registry;
217
- registerRows(labels("hooked", "Cards", ["Hooked row"]));
218
- const host = createFakeHost();
219
- const customBefore = host.ui.custom;
220
-
221
- const entry = armThroughEntry(host.ui);
222
-
223
- assert.equal(host.ui.custom, customBefore, "capture patches nothing when the hook exists");
224
- assert.equal(registry.length, 1, "one fan-out provider in the registry");
225
- assert.deepEqual(
226
- registry[0].rows({}).map((row) => row.label),
227
- ["Hooked row"],
228
- "rows land through the registry's own call path",
229
- );
230
- assert.match(printStatus(entry), /^backend: hook$/m);
231
- });
232
-
233
- test("createStore writes atomically under the real config-home override", () => {
234
- const store = createStore("e2e");
235
- const bridgeDir = join(configHome, "gentle-shell-bridge");
236
-
237
- store.set("bannerSource", "output");
238
-
239
- assert.ok(store.path.startsWith(bridgeDir), "the store path follows GENTLE_PI_CONFIG_HOME");
240
- assert.equal(store.path, join(bridgeDir, "e2e.json"));
241
- assert.equal(store.get("bannerSource", "activity"), "output");
242
- const entries = readdirSync(bridgeDir);
243
- assert.ok(entries.includes("e2e.json"));
244
- assert.deepEqual(entries.filter((name) => name.endsWith(".tmp")), [], "no temp file remains");
245
- const document = JSON.parse(readFileSync(store.path, "utf8"));
246
- assert.equal(document.schemaVersion, STORE_SCHEMA_VERSION);
247
- assert.deepEqual(document.values, { bannerSource: "output" });
248
- });
249
-
250
- test("a malformed store file falls back, is reported by bridge-status, and is fixed by a later set", () => {
251
- const bridgeDir = join(configHome, "gentle-shell-bridge");
252
- mkdirSync(bridgeDir, { recursive: true });
253
- const path = join(bridgeDir, "broken.json");
254
- writeFileSync(path, "{ not json", "utf8");
255
- const store = createStore("broken");
256
-
257
- assert.equal(store.get("bannerSource", "activity"), "activity");
258
- const reported = printStatus(loadEntry());
259
- assert.match(reported, /^store problems: 1$/m);
260
- assert.match(reported, /broken \(.*broken\.json\): invalid-json/);
261
- assert.equal(readFileSync(path, "utf8"), "{ not json", "reads never repair the file");
262
-
263
- store.set("bannerSource", "output");
264
-
265
- const document = JSON.parse(readFileSync(path, "utf8"));
266
- assert.equal(document.schemaVersion, 1);
267
- assert.deepEqual(document.values, { bannerSource: "output" });
268
- assert.match(printStatus(loadEntry()), /^store problems: none$/m);
269
- });
270
-
271
- test("S8 end to end: action persists first and the repaint shows the new bullet", async () => {
272
- const store = createStore("e2e-toggle");
273
- const toggle = (value: string): CustomizeRow => ({
274
- category: "Banner",
275
- label: () => "Loon banner source",
276
- choice: {
277
- setting: "Loon banner source",
278
- value,
279
- current: () => store.get("bannerSource", "activity") === value,
280
- },
281
- action: () => {
282
- store.set("bannerSource", value); // persist first
283
- }, // the view repaints second
284
- });
285
- registerRows({
286
- id: "loon.banner-source",
287
- category: "Banner",
288
- rows: () => [toggle("activity"), toggle("output")],
289
- });
290
- const host = createFakeHost();
291
- armThroughEntry(host.ui);
292
- host.buildMenu();
293
-
294
- assert.deepEqual(host.toggleLines(), ["Loon banner source ● activity ○ output"]);
295
-
296
- await host.pressRow((row) => row.choice?.value === "output");
297
-
298
- assert.equal(store.get("bannerSource", "activity"), "output", "action persisted before repaint");
299
- assert.deepEqual(
300
- host.toggleLines(),
301
- // Row order is fixed by the menu; only the rendered bullet moves onto the new value.
302
- ["Loon banner source ○ activity ● output"],
303
- "the same menu repaints onto the new value, with no second build",
304
- );
305
- assert.equal(host.factoryCallCount(), 1, "no second menu build was needed");
306
- });
307
-
308
- test("the public entry exports exactly the contract surface and no test seams", () => {
309
- assert.deepEqual(Object.keys(publicApi).sort(), [
310
- "BRIDGE_READY_EVENT",
311
- "KEYMAP_RESOLVER_KEY",
312
- "PLUGIN_SURFACE_KEY",
313
- "STORE_SCHEMA_VERSION",
314
- "VERSION",
315
- "bridgeApi",
316
- "createStore",
317
- "isBridgeApi",
318
- "onBridgeApi",
319
- "registerCardStyle",
320
- "registerCommands",
321
- "registerKeymap",
322
- "registerRows",
323
- "registerSection",
324
- "registerToolDesign",
325
- "registerTransform",
326
- "resolveKeymap",
327
- ]);
328
- for (const seam of [
329
- "installCardStyleTransform",
330
- "disableCardStyleTransform",
331
- "getCardStyleWiringDiagnostics",
332
- "resetCardStyleWiringForTests",
333
- "transformCardLines",
334
- "getCardStyleTransformFailures",
335
- "getCardStyleLastOutcome",
336
- "resetCardStyleTransformForTests",
337
- "installCardStyleRows",
338
- "cardStyleRowMode",
339
- "extendCardStyleLine",
340
- "setCardStyleSelection",
341
- "getCardStyleSelection",
342
- "resolveCardStyleSelection",
343
- "snapshotCardStyles",
344
- "resetCardStylesForTests",
345
- "CARD_STYLE_NATIVE",
346
- "HOST_CARD_STYLE_SETTING",
347
- "resetBusForTests",
348
- "resetCaptureStateForTests",
349
- "resetHookStateForTests",
350
- "resetStoreStateForTests",
351
- "snapshotSections",
352
- "resetSectionsForTests",
353
- "snapshotProviders",
354
- "installSectionRail",
355
- "installSectionRows",
356
- "setRailContributor",
357
- "getSectionFailures",
358
- "getSidebarDiagnostics",
359
- "resetBridgeApiForTests",
360
- "publishBridgeApi",
361
- "BRIDGE_API_KEY",
362
- "snapshotTransforms",
363
- "resetTransformsForTests",
364
- "applyTransforms",
365
- "getTransformFrameOutcomes",
366
- "resetTransformPipelineForTests",
367
- ]) {
368
- assert.equal(seam in publicApi, false, `${seam} must not be public`);
369
- }
370
- assert.equal(typeof STATUS_COMMAND, "string");
371
- assert.ok(STATUS_COMMAND.length > 0);
372
- });
373
-
374
- test("the documented values and types are reachable from the public entry", () => {
375
- const provider: RowProvider = { id: "api", category: "Cards", rows: (): CustomizeRow[] => [] };
376
- // The card-style surface: one registration, three functions, the contexts they receive.
377
- const cardStyle: CardStyleRegistration = {
378
- id: "api.cute",
379
- label: "cute",
380
- order: 1,
381
- frame: {
382
- top: (frameCtx: CardFrameTopContext) => `${frameCtx.title ?? ""}${frameCtx.hint ?? ""}`,
383
- line: (frameCtx: CardFrameLineContext) => `${frameCtx.text}${frameCtx.first}`,
384
- bottom: (frameCtx: CardFrameBottomContext) => String(frameCtx.width),
385
- },
386
- preview: (): RowPreview => ({ title: "Cute", sample: "sample" }),
387
- };
388
- const context: RowContext = { theme: undefined, configHome: configHome };
389
- const choice: RowChoice = { setting: "s", value: "v", current: () => true };
390
- const label: RowLabel = "text";
391
- const preview: RowPreview = { title: "t", sample: "s" };
392
- const helpers: InlineHelpers = {
393
- input: async () => undefined,
394
- confirm: async () => true,
395
- capture: async () => undefined,
396
- };
397
- const store: Store = createStore("api-surface");
398
- const kind: StoreProblemKind = "invalid-json";
399
- const problem: StoreProblem = { pluginId: "api", path: "/tmp/x", kind, message: "m" };
400
- // The section surface is public too: a section is written by a plugin and registered with one call.
401
- const frame: CardFrame = cardStyle.frame;
402
- assert.equal(typeof frame.bottom, "function");
403
- const section: SidebarSection = {
404
- id: "api.section",
405
- title: "API",
406
- meta: (sectionCtx: SectionContext) => sectionCtx.configHome,
407
- lines: (sectionCtx: SectionContext) => [sectionCtx.configHome],
408
- defaultVisible: true,
409
- order: 1,
410
- };
411
- // The transform surface: one function over the rows, plus the context it receives.
412
- const transform: TranscriptTransform = {
413
- id: "api.transform",
414
- order: 2,
415
- transform: (lines: readonly string[], transformCtx: TransformContext) => [
416
- ...lines,
417
- String(transformCtx.width),
418
- ],
419
- };
420
-
421
- assert.equal(provider.rows(context).length, 0);
422
- assert.equal(typeof publicApi.registerCardStyle, "function", "the entry exports the registration");
423
- assert.equal(
424
- typeof publicApi.registerTransform,
425
- "function",
426
- "and the transcript-transform registration",
427
- );
428
- assert.equal(cardStyle.frame.top({ width: 1, title: "t" }), "t");
429
- assert.equal(cardStyle.frame.line({ text: "x", width: 1, first: true }), "xtrue");
430
- assert.equal(cardStyle.frame.bottom({ width: 7 }), "7");
431
- assert.deepEqual(cardStyle.preview?.(), { title: "Cute", sample: "sample" });
432
- assert.equal(choice.value, "v");
433
- assert.equal(label, "text");
434
- assert.deepEqual(preview, { title: "t", sample: "s" });
435
- assert.equal(typeof helpers.confirm, "function");
436
- assert.equal(store.pluginId, "api-surface");
437
- assert.equal(problem.kind, "invalid-json");
438
- assert.equal(STORE_SCHEMA_VERSION, 1);
439
- assert.equal(section.title, "API");
440
- assert.equal(section.lines(context).length, 1);
441
- assert.deepEqual(transform.transform(["a"], { width: 3 }), ["a", "3"]);
442
- assert.equal(VERSION, "0.3.0");
443
- });