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
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
- });