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
Binary file
@@ -1,30 +0,0 @@
1
- # ODD — plugin sections in gentle's command palette
2
-
3
- Two repos: `gentle-shell-bridge` (the API plugins call) and `gentle-shell`/gentle-pi (owner of `/gentle:commands`). Size: large (cross-repo). The user calls the palette "panel de gentle shell commands".
4
-
5
- ## Specs
6
-
7
- - **S1** — A plugin adds its entries to the palette in either of two shapes: into an existing section, or as a new section of its own. User: "agregar seccioones a panel de gentle shell comands, ya sea a una seccion exxistente o con categoria nueva en commands".
8
- - **S2** — A contributed entry behaves like a native one: the palette lists it only while its command is registered, and selecting it runs that command.
9
- - **S3** — Disposing the registration removes the entry, or the section when it was the plugin's own and is left empty.
10
- - **S4** — Without host support the registration reports an unavailable reason through `/bridge-status`, never a silent no-op.
11
- - **S5** — Order and grouping stay the host's: a contributed section appears with the host's sections and does not reorder them.
12
-
13
- ## Tasks
14
-
15
- - **T1** (S1, S5) — Host: catalog accepts contributed sections/items at build time, in catalog order. Route: inline.
16
- - **T2** (S1) — Bridge: `registerCommands` (existing section) and a section of its own, with disposer. Route: inline.
17
- - **T3** (S2) — Selecting a contributed entry runs the plugin's command, including when the command disappears mid-session.
18
- - **T4** (S3) — Dispose removes entry/section at the next palette open.
19
- - **T5** (S4) — Unavailable path reported through `/bridge-status`.
20
- - **T6** — Docs: contract.md, README, host docs.
21
-
22
- ## Log
23
-
24
- - **L1** — "ahora veremos si podemos agregar 2 feactures para bridge, una es para agregar algun aparatado a panel commands [Image#1] ya sea en una seccion existe o propia, que seria ahi acciones rapidas como abrir panel etc, las que tiene."
25
- - **L2** — "que preguntas, sabes siquiera de que hablamos? que es este plugin bridge, ya tenemos arto integrado, ni revisaste el contexto del proyecto"
26
- - **L3** — "otro para agregar seccioones a panel de gentle shell comands, ya sea a una seccion exxistente o con categoria nueva en commands"
27
- - **L4** (evidence, explorer mwgxgx3x-1-onuz) — The palette is a curated static catalog, "not a raw listing of every registered extension command" (`gentle-shell/lib/command-palette-catalog.ts:2-4`); `buildCommandPaletteGroups` keeps only catalog entries that are registered (`:33-49`). No extension point exists for a third-party group or item.
28
- - **L5** (commits) — Host: `33bbe4e84` (merge of contributions in `lib/command-palette-catalog.ts`, `CONTRIBUTED_SECTION` = "Plugins" for an item that names no section). Bridge: `85f0f2b` (`registerCommands`, publishing on the same surface as keymaps).
29
- - **L6** (evidence) — Covered by `tests/plugin-surface.test.ts`: joins a named section, opens an own section after the catalog, and an unregistered command is not listed (also for an own section that would be left empty).
30
- - **L8** (delivery) — Same two PRs as plugin-keymap (bridge #6, host #80), both merged. CI never started on the host fork (0 runs for the head SHA).
@@ -1,32 +0,0 @@
1
- # ODD — plugin keymaps in gentle's Customize Keymap page
2
-
3
- Two repos: `gentle-shell-bridge` (the API plugins call) and `gentle-shell`/gentle-pi (the owner of the Keymaps page, the bindings file and the collision rules). Size: large (multi-session, cross-repo, contract-level).
4
-
5
- ## Specs
6
-
7
- - **S1** — A plugin registers one keymapped action through the bridge and the host lists it on the `Keymaps` page of `/gentle:customize`. User: "registrar keymap en customize keymap, para que aparezca ahi".
8
- - **S2** — That row is edited with the host's own gestures on that page (Enter assigns, ⌫ disables, r restores the default), persists where the host persists its keymaps and applies after `/reload`. User: "se pueda modificar desde ahi el keymap del plugin" and "sumo compativlidad para modificar mendiante customize keymap de gentle".
9
- - **S3** — The binding is real, not advertised: the plugin can resolve its own effective key and register the shortcut itself, so the row never claims a key that does nothing.
10
- - **S4** — The host stays the single registry: environment overrides, reserved Pi keys and duplicate-key checks keep working for plugin ids exactly as for native ones. User's "compatibilidad" means the native rules, not a bypass.
11
- - **S5** — Disposing the registration removes the plugin's row; a stale row or a stale key must not survive it.
12
- - **S6** — Without host support, `registerKeymap` reports an unavailable reason through `/bridge-status`; it never fails silently.
13
-
14
- ## Tasks
15
-
16
- - **T1** (S1, S6) — Host: extension-facing registry for keymap actions, and the bridge reads it. Route: inline.
17
- - **T2** (S1, S2) — Host: `buildKeymapRows` and the collision helpers accept registered ids. Route: inline.
18
- - **T3** (S1, S3) — Bridge: `registerKeymap` in the published API, disposer, and effective-key resolution for the plugin. Route: inline.
19
- - **T4** (S4) — Collision + env-lock parity pinned by tests on both sides. Route: inline.
20
- - **T5** (S2, S5) — End-to-end: register, reassign from the page, `/reload`, key works; dispose, key gone.
21
- - **T6** — Docs: contract.md, README, and the host's docs for the new surface.
22
-
23
- ## Log
24
-
25
- - **L1** — "ahora veremos si podemos agregar 2 feactures para bridge, una es para agregar algun aparatado a panel commands [Image#1] ya sea en una seccion existe o propia, que seria ahi acciones rapidas como abrir panel etc, las que tiene. Y otra para registrar keymap en customize keymap, para que aparezca ahi y se pueda modificar desde ahi el keymap del plugin integra y sumo compativlidad para modificar mendiante customize keymap de gentle."
26
- - **L2** — "que preguntas, sabes siquiera de que hablamos? que es este plugin bridge, ya tenemos arto integrado, ni revisaste el contexto del proyecto"
27
- - **L3** — "Y ahora ve como integrar lo que pedi, el bridge para registrar keymap de un plugin en gentle customize keymap. y otro para agregar seccioones a panel de gentle shell comands, ya sea a una seccion exxistente o con categoria nueva en commands"
28
- - **L4** (evidence, explorer mwgxgx3x-1-onuz) — Host today: rows only from the static `KEYMAP_ACTIONS` (`gentle-shell/lib/keymaps.ts:34`) plus curated-catalog commands (`lib/keymaps-customize.ts:64-67`); no third-party registry of any kind; `keymaps.json` persists unknown ids (`parseKeymapFile`) but `keymapOwner`/`checkKeymap` ignore them, so an unknown id gets no row, no binding and no collision check. Bridge refuses the category at `src/row-names.ts:119-120` and `src/bus.ts:121-138`.
29
- - **L5** (commits) — Host half: `33bbe4e84` on `feat/plugin-surfaces` in the gentle-shell worktree (5 files, 320 lines): `lib/plugin-surface.ts` reader, contributed actions in `lib/keymaps.ts` (lookup, effective key, source, collisions), the published keymap resolver, and `lib/keymaps-customize.ts` listing them. Bridge half: `85f0f2b` on `feat/plugin-surfaces` (6 files, 398 lines): `src/plugin-surface.ts`, the three optional API members, and the export/API contract lists.
30
- - **L6** (evidence) — Host tests: `tests/plugin-surface.test.ts` 12/12; with `keymaps` and `keymaps-customize`, 25/26 — the one failure is `tests/command-palette.test.ts` unable to resolve `@earendil-works/pi-coding-agent` because neither checkout has `node_modules` installed (pre-existing, environment). Bridge tests: 691/691.
31
- - **L7** (delivery defect + fix) — The first delivery was NOT visible: Pi loads the host from the installed git checkout (`/home/jona/.pi/agent/git/github.com/Gentleman-Programming/gentle-shell`, at 3dc902975) and the bridge from `gentle-shell-bridge-worktrees/bash-card-identity`, neither of which had this work. Restarting Pi could not help. Fix, applied to the running copies: `git apply` of the host patch onto the installed checkout (its `lib/keymaps.ts`, `lib/keymaps-customize.ts`, `lib/command-palette-catalog.ts` are byte-identical to the base commit, so it applies cleanly), and the bridge package entry in `~/.pi/agent/settings.json` repointed to `../../dev/projects/gentle-shell-bridge` (backup `settings.json.bak-plugin-surface`). Verified by importing the INSTALLED `lib/keymaps.ts`/`lib/keymaps-customize.ts`/`lib/command-palette-catalog.ts` with a published surface: action listed, row `Bash card: report state: alt+b`, palette group `Panel`, and the resolver answering `alt+b`. The installed checkout is now a dirty working tree: a package update replaces it and the patch must be reapplied (or the branch merged upstream).
32
- - **L9** (delivery) — Bridge PR jonasotoaguilar/gentle-shell-bridge#6 merged as `90704ca`; host PR jonasotoaguilar/gentle-shell#80 merged as `b61559cf6`. Both merge commits, no squash. CI on the host fork never ran: `actions/runs?head_sha=33bbe4e8…` returned `total_count: 0` while the workflow is active and Actions is enabled — a GitHub-side condition, not a code failure; the proof for this change is the local suites plus the live TUI confirmation (L8). The installed upstream checkout still carries the hand-applied patch, so it is unaffected by the fork merge.
@@ -1,55 +0,0 @@
1
- # Wire contract — the bridge publishes, the host reads
2
-
3
- Two repos, one handshake. The bridge already publishes its API at module load on
4
- `Symbol.for("gentle-shell-bridge/api/v1")`; this contract adds a second, narrower global that
5
- holds the plugin contributions the host must render. Direction matters: **the bridge publishes
6
- and the host reads lazily**, so no load order, no session_start race, and no lifecycle in the host.
7
-
8
- ## The global
9
-
10
- ```ts
11
- Symbol.for("gentle-shell-bridge/plugin-surface/v1")
12
- ```
13
-
14
- ```ts
15
- interface PluginSurface {
16
- schema: 1;
17
- /** Live: the host calls this per menu/palette build, never once at startup. */
18
- keymaps(): readonly PluginKeymapAction[];
19
- commands(): readonly PluginCommandContribution[];
20
- }
21
-
22
- interface PluginKeymapAction {
23
- /** Plugin-owned, stable, never a built-in id and never `command:<name>`. */
24
- id: string;
25
- label: string;
26
- /** Optional environment variable that overrides the key, same semantics as a built-in action. */
27
- env?: string;
28
- defaultKey?: string;
29
- /** The slash command this action invokes, when it has one. */
30
- command?: string;
31
- }
32
-
33
- interface PluginCommandContribution {
34
- id: string;
35
- /** An existing palette section title, or a new one the plugin owns. */
36
- section?: string;
37
- label: string;
38
- /** The slash command the palette runs. Listed only while this command is registered. */
39
- command: string;
40
- }
41
- ```
42
-
43
- ## Rules both sides obey
44
-
45
- 1. **Absent or malformed is empty.** A host that finds no global, a wrong `schema`, a throwing
46
- getter or a bad entry renders nothing from it and never throws. Fail closed, entry by entry.
47
- 2. **Live reads.** The host calls `keymaps()`/`commands()` at build time. A registration made
48
- during `session_start` is visible at the next menu or palette open; nothing is cached at startup.
49
- 3. **The host keeps ownership of its own rules.** A contributed key obeys the same reserved Pi
50
- keys, environment lock and duplicate-key checks as a native one; a contributed id takes part in
51
- collision detection exactly like a built-in id. No bypass.
52
- 4. **The plugin binds its own key.** `pi.registerShortcut` is called by the plugin (or the bridge
53
- on its behalf), never invented by the host; the host only renders the row and checks the key.
54
- 5. **No new host-side lifecycle.** The host never disposes a contribution. Removal is the bridge's
55
- disposer, and the next build simply stops seeing it.
package/test/api.test.ts DELETED
@@ -1,395 +0,0 @@
1
- /**
2
- * The runtime handshake (S1, S5 "zero extra steps"): the published API object, its shape gate and
3
- * the `bridgeApi()` helper a plugin calls from its own `session_start` handler.
4
- *
5
- * A plugin and the bridge are two independent projects. Pi gives them one sanctioned channel that
6
- * needs no path: `globalThis`. These tests pin that channel only — the symbol, the frozen object,
7
- * the gate and the absent-means-absent rule — because a path or an environment variable is exactly
8
- * what the rejected design offered and what this design replaces.
9
- */
10
-
11
- import assert from "node:assert/strict";
12
- import { mkdtempSync, rmSync } from "node:fs";
13
- import { tmpdir } from "node:os";
14
- import { join } from "node:path";
15
- import test from "node:test";
16
-
17
- import {
18
- API_SCHEMA,
19
- API_VERSION,
20
- BRIDGE_API_KEY,
21
- VERSION,
22
- bridgeApi,
23
- isBridgeApi,
24
- publishBridgeApi,
25
- resetBridgeApiForTests,
26
- } from "../src/api.ts";
27
- import {
28
- resetBusForTests,
29
- resetSectionsForTests,
30
- snapshotProviders,
31
- snapshotSections,
32
- } from "../src/bus.ts";
33
- import { disableCaptureBackend, getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
34
- import gentleShellBridge, {
35
- type BridgeExtensionApi,
36
- type BridgeSessionHandler,
37
- } from "../src/extension.ts";
38
- import { resetHookStateForTests } from "../src/hook.ts";
39
- import { resetNoticesForTests, snapshotNotices } from "../src/notices.ts";
40
- import { resetRowBuildStateForTests } from "../src/rows.ts";
41
- import {
42
- disableSidebarBackend,
43
- resetSidebarStateForTests,
44
- } from "../src/sidebar.ts";
45
- import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
46
- import { resetStoreStateForTests } from "../src/store.ts";
47
- import type { RowProvider, SidebarSection } from "../src/types.ts";
48
- import { createFakeHost, type FakeHost, type FakeUiTarget } from "./fake-host.ts";
49
- import {
50
- cardBody,
51
- cardTitles,
52
- fakeTui,
53
- registerNativeFooter,
54
- resetSidebar,
55
- } from "./fake-sidebar.ts";
56
-
57
- const hostSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
58
-
59
- const provider = (id: string): RowProvider => ({ id, category: "Cards", rows: () => [] });
60
- const section = (id: string, title = "MUSIC"): SidebarSection => ({
61
- id,
62
- title,
63
- meta: () => "1 playing",
64
- lines: () => ["track one"],
65
- });
66
-
67
- /** Every terminal write the process would show; the bridge must produce none. */
68
- const captureConsole = (): { writes: string[]; restore: () => void } => {
69
- const writes: string[] = [];
70
- const sink = (globalThis as unknown as { console: { log: unknown; warn: unknown; error: unknown } })
71
- .console;
72
- const original = { log: sink.log, warn: sink.warn, error: sink.error };
73
- const record =
74
- (method: string) =>
75
- (...args: unknown[]): void => {
76
- writes.push(`${method}: ${args.map((arg) => String(arg)).join(" ")}`);
77
- };
78
- sink.log = record("log");
79
- sink.warn = record("warn");
80
- sink.error = record("error");
81
- return {
82
- writes,
83
- restore: () => {
84
- sink.log = original.log;
85
- sink.warn = original.warn;
86
- sink.error = original.error;
87
- },
88
- };
89
- };
90
-
91
- let configHome: string;
92
- let previousConfigHome: string | undefined;
93
-
94
- test.before(() => {
95
- configHome = mkdtempSync(join(tmpdir(), "gsb-api-"));
96
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
97
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
98
- });
99
-
100
- test.after(() => {
101
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
102
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
103
- rmSync(configHome, { recursive: true, force: true });
104
- });
105
-
106
- test.beforeEach(() => {
107
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
108
- resetBridgeApiForTests();
109
- resetNoticesForTests();
110
- resetBusForTests();
111
- resetSectionsForTests();
112
- resetSidebar();
113
- resetSidebarStateForTests();
114
- resetSectionFailuresForTests();
115
- resetStoreStateForTests();
116
- resetCaptureStateForTests();
117
- resetHookStateForTests();
118
- resetRowBuildStateForTests();
119
- disableCaptureBackend();
120
- disableSidebarBackend();
121
- });
122
-
123
- test.afterEach(() => {
124
- disableCaptureBackend();
125
- disableSidebarBackend();
126
- });
127
-
128
- // ---------------------------------------------------------------------------------------------
129
- // The published object
130
- // ---------------------------------------------------------------------------------------------
131
-
132
- test("publishing stores one frozen, versioned API object at the stable symbol", () => {
133
- const api = publishBridgeApi();
134
-
135
- assert.equal(hostSlot()[BRIDGE_API_KEY], api, "the slot holds exactly the object published");
136
- assert.ok(Object.isFrozen(api), "the published contract is frozen: nobody may extend it in place");
137
- assert.deepEqual(Object.keys(api).sort(), [
138
- "VERSION",
139
- "createStore",
140
- "registerCardStyle",
141
- "registerCommands",
142
- "registerKeymap",
143
- "registerRows",
144
- "registerSection",
145
- "registerToolDesign",
146
- "registerTransform",
147
- "requestRepaint",
148
- "resolveKeymap",
149
- "schema",
150
- "version",
151
- ]);
152
- assert.equal(api.schema, "gentle-shell-bridge/api");
153
- assert.equal(api.version, 1);
154
- assert.equal(api.VERSION, VERSION);
155
- assert.equal(api.schema, API_SCHEMA);
156
- assert.equal(api.version, API_VERSION);
157
- assert.equal(isBridgeApi(api), true);
158
- });
159
-
160
- test("the published functions are the working ones, not a stale copy", () => {
161
- const api = publishBridgeApi();
162
-
163
- const disposeRows = api.registerRows(provider("loon.banner-source"));
164
- const disposeSection = api.registerSection(section("loon.music"));
165
-
166
- assert.deepEqual(snapshotProviders().map((registered) => registered.id), ["loon.banner-source"]);
167
- assert.deepEqual(snapshotSections().map((registered) => registered.id), ["loon.music"]);
168
-
169
- disposeSection();
170
- disposeRows();
171
- assert.deepEqual(snapshotProviders(), []);
172
- assert.deepEqual(snapshotSections(), []);
173
- });
174
-
175
- test("the shape gate accepts only the v1 surface it advertises", () => {
176
- const valid = {
177
- schema: API_SCHEMA,
178
- version: API_VERSION,
179
- registerRows: () => () => {},
180
- registerSection: () => () => {},
181
- createStore: () => ({}),
182
- };
183
-
184
- assert.equal(isBridgeApi(valid), true);
185
-
186
- // Every mismatch is "absent", so the caller sees `undefined` instead of an error path.
187
- for (const rejected of [
188
- undefined,
189
- null,
190
- "gentle-shell-bridge",
191
- 42,
192
- {},
193
- { ...valid, schema: "gentle-shell-bridge/api/v2" },
194
- { ...valid, version: 2 },
195
- { ...valid, version: "1" },
196
- { schema: API_SCHEMA, version: API_VERSION },
197
- { ...valid, registerRows: undefined },
198
- { ...valid, registerSection: "nope" },
199
- { ...valid, createStore: {} },
200
- ]) {
201
- assert.equal(isBridgeApi(rejected), false, `must reject ${JSON.stringify(rejected)}`);
202
- }
203
- });
204
-
205
- test("a second publish keeps the first object: first wins", () => {
206
- const first = publishBridgeApi();
207
- const second = publishBridgeApi();
208
-
209
- assert.equal(second, first);
210
- assert.equal(hostSlot()[BRIDGE_API_KEY], first);
211
- });
212
-
213
- test("two loaded copies of the module publish one object and both work through it", async () => {
214
- const first = publishBridgeApi();
215
-
216
- // Pi loads every extension with `moduleCache: false`, so this package genuinely exists twice in
217
- // one process. A second copy is a second module instance, not a second global slot.
218
- const copyPath = new URL("../src/api.ts?second-copy", import.meta.url).href;
219
- const second = (await import(copyPath)) as typeof import("../src/api.ts");
220
-
221
- assert.equal(hostSlot()[BRIDGE_API_KEY], first, "the second copy never overwrote the slot");
222
- const secondApi = second.publishBridgeApi();
223
- assert.equal(secondApi, first, "and publishing again hands the same object back");
224
- assert.equal(second.isBridgeApi(hostSlot()[BRIDGE_API_KEY]), true);
225
-
226
- // Both copies register into the same process-global bus, which is what makes them interchangeable.
227
- first.registerRows(provider("shared"));
228
- secondApi.registerRows(provider("shared"));
229
- assert.deepEqual(snapshotProviders().map((registered) => registered.id), ["shared"]);
230
- secondApi.registerSection(section("shared.section"));
231
- assert.deepEqual(snapshotSections().map((registered) => registered.id), ["shared.section"]);
232
- });
233
-
234
- // ---------------------------------------------------------------------------------------------
235
- // bridgeApi(): the object or undefined, never a wait
236
- // ---------------------------------------------------------------------------------------------
237
-
238
- test("bridgeApi returns undefined when nothing is published, and the object once it is", () => {
239
- assert.equal(bridgeApi(), undefined, "no publication is absent, full stop");
240
-
241
- const api = publishBridgeApi();
242
- assert.equal(bridgeApi(), api, "and the published object is handed back immediately");
243
- });
244
-
245
- test("an object that fails the gate is absent: bridgeApi returns undefined, one notice, no throw", () => {
246
- for (const incompatible of [
247
- { ...usableSurface(), schema: "someone-else/api" },
248
- { ...usableSurface(), version: 2 },
249
- { schema: API_SCHEMA, version: API_VERSION },
250
- ]) {
251
- resetBridgeApiForTests();
252
- resetNoticesForTests();
253
- hostSlot()[BRIDGE_API_KEY] = incompatible;
254
- const captured = captureConsole();
255
- let seen: unknown;
256
- try {
257
- assert.doesNotThrow(() => {
258
- seen = bridgeApi();
259
- });
260
- } finally {
261
- captured.restore();
262
- }
263
-
264
- assert.equal(seen, undefined, "a mismatched object is never handed to a plugin");
265
- assert.equal(hostSlot()[BRIDGE_API_KEY], incompatible, "the slot is left exactly as it was");
266
- assert.deepEqual(captured.writes, [], "the skew is never written to the terminal");
267
- const notices = snapshotNotices().entries;
268
- assert.equal(notices.length, 1, "and it is recorded once, not per call");
269
- assert.match(notices[0].message, /incompatible|unsupported|version|not the/u);
270
- assert.match(notices[0].message, /absent|ABSENT/u);
271
- assert.match(notices[0].message, /left/u);
272
- assert.doesNotMatch(notices[0].message, /queue/u, "the notice never mentions a removed queue");
273
- }
274
- });
275
-
276
- const usableSurface = (): Record<string, unknown> => ({
277
- schema: API_SCHEMA,
278
- version: API_VERSION,
279
- registerRows: () => () => {},
280
- registerSection: () => () => {},
281
- createStore: () => ({ get: () => undefined, set: () => {} }),
282
- });
283
-
284
- test("an incompatible slot is never reset, and publishing still yields a working API", () => {
285
- const incompatible = { ...usableSurface(), version: 2 };
286
- hostSlot()[BRIDGE_API_KEY] = incompatible;
287
- const captured = captureConsole();
288
- let published: ReturnType<typeof publishBridgeApi> | undefined;
289
- try {
290
- published = publishBridgeApi();
291
- } finally {
292
- captured.restore();
293
- }
294
-
295
- assert.deepEqual(captured.writes, []);
296
- assert.equal(hostSlot()[BRIDGE_API_KEY], incompatible, "the slot is left exactly as it was");
297
- assert.equal(isBridgeApi(hostSlot()[BRIDGE_API_KEY]), false);
298
- assert.equal(bridgeApi(), undefined, "and consumers still see it as absent");
299
- assert.ok(published !== undefined, "this copy still has an object to register through");
300
- assert.equal(isBridgeApi(published), true);
301
- published.registerSection(section("loon.music"));
302
- assert.deepEqual(snapshotSections().map((registered) => registered.id), ["loon.music"]);
303
- });
304
-
305
- // ---------------------------------------------------------------------------------------------
306
- // Through the entry: a plugin registers from its own session_start handler
307
- // ---------------------------------------------------------------------------------------------
308
-
309
- type UiWithWidget = FakeUiTarget & {
310
- setWidget?: (key: string, factory: (tui: unknown, theme: unknown) => unknown) => void;
311
- };
312
-
313
- const installWidgetSurface = (host: FakeHost): void => {
314
- const ui = host.ui as UiWithWidget;
315
- ui.setWidget = (_key, factory) => {
316
- factory(fakeTui(), { name: "fake-theme" });
317
- };
318
- };
319
-
320
- const armThroughEntry = (host: FakeHost): void => {
321
- const handlers: BridgeSessionHandler[] = [];
322
- const pi: BridgeExtensionApi = {
323
- // `gentle-pi` is present, so `session_start` arms (S9/T12).
324
- getCommands: () => [{ name: "gentle:customize" }],
325
- on(event, handler) {
326
- if (event === "session_start") handlers.push(handler);
327
- },
328
- registerCommand() {},
329
- };
330
- gentleShellBridge(pi);
331
- assert.equal(handlers.length, 1);
332
- handlers[0]({}, { hasUI: true, ui: host.ui });
333
- };
334
-
335
- test("a plugin that registers from session_start renders its section and its Sections toggle", () => {
336
- const host = createFakeHost();
337
- installWidgetSurface(host);
338
- registerNativeFooter(); // the host's own rail
339
-
340
- publishBridgeApi(); // the bridge loaded, as it always does before any session_start
341
-
342
- const api = bridgeApi();
343
- assert.ok(api !== undefined, "the API is present in the handler: no waiting, no queue");
344
- api.registerRows({
345
- id: "loon.banner-source",
346
- category: "Cards",
347
- rows: () => [
348
- {
349
- category: "Cards",
350
- label: "Loon banner source",
351
- choice: { setting: "Loon banner source", value: "on", current: () => true },
352
- action: () => {},
353
- },
354
- ],
355
- });
356
- api.registerSection(section("loon.music"));
357
- assert.deepEqual(snapshotProviders().map((registered) => registered.id), ["loon.banner-source"]);
358
- assert.deepEqual(snapshotSections().map((registered) => registered.id), ["loon.music"]);
359
-
360
- armThroughEntry(host); // session_start
361
- host.buildMenu();
362
-
363
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"], "the registered section reached the rail");
364
- assert.deepEqual(cardBody("MUSIC"), ["track one"]);
365
- const toggle = host.toggleLines().find((line) => line.startsWith("music "));
366
- assert.ok(toggle !== undefined, "and its toggle reached Sections");
367
- assert.ok(toggle.includes("● shown") && toggle.includes("○ hidden"));
368
- assert.ok(
369
- host.toggleLines().some((line) => line.startsWith("Loon banner source")),
370
- "the provider's rows reached the menu too",
371
- );
372
- });
373
-
374
- test("PRESERVE: a session_start without a UI still arms nothing", () => {
375
- const handlers: BridgeSessionHandler[] = [];
376
- const pi: BridgeExtensionApi = {
377
- getCommands: () => [{ name: "gentle:customize" }],
378
- on(_event, handler) {
379
- handlers.push(handler);
380
- },
381
- registerCommand() {},
382
- };
383
- gentleShellBridge(pi);
384
- publishBridgeApi();
385
- bridgeApi()?.registerRows(provider("queued.rows"));
386
-
387
- handlers[0]({}, { hasUI: false });
388
-
389
- assert.equal(getCaptureDiagnostics().armedTargets, 0, "no UI still means no arming");
390
- assert.deepEqual(
391
- snapshotProviders().map((registered) => registered.id),
392
- ["queued.rows"],
393
- "registration from a plugin handler is independent of the bridge arming",
394
- );
395
- });