gentle-shell-bridge 0.0.0-stage → 0.3.0

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 (112) hide show
  1. package/.codegraph/.gitignore +5 -0
  2. package/PRD.md +144 -0
  3. package/README.md +745 -2
  4. package/docs/architecture.md +981 -0
  5. package/docs/contract.md +1266 -0
  6. package/docs/state.md +251 -0
  7. package/examples/bash-card.ts +1694 -0
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +30 -0
  10. package/odd/tasks/plugin-keymap.md +32 -0
  11. package/odd/tasks/plugin-surface-contract.md +55 -0
  12. package/package.json +35 -4
  13. package/src/api.ts +492 -0
  14. package/src/arm.ts +83 -0
  15. package/src/bus.ts +558 -0
  16. package/src/capture.ts +886 -0
  17. package/src/card-style-line.ts +112 -0
  18. package/src/card-style-rows.ts +390 -0
  19. package/src/card-style-transform.ts +862 -0
  20. package/src/card-style-wiring.ts +472 -0
  21. package/src/card-styles.ts +159 -0
  22. package/src/config.ts +71 -0
  23. package/src/extension.ts +271 -0
  24. package/src/hook.ts +310 -0
  25. package/src/index.ts +71 -0
  26. package/src/notices.ts +137 -0
  27. package/src/plugin-lines.ts +865 -0
  28. package/src/plugin-surface.ts +218 -0
  29. package/src/presence.ts +259 -0
  30. package/src/repaint.ts +351 -0
  31. package/src/row-labels.ts +146 -0
  32. package/src/row-names.ts +273 -0
  33. package/src/row-origin.ts +144 -0
  34. package/src/rows.ts +248 -0
  35. package/src/section-panel.ts +378 -0
  36. package/src/section-pointer.ts +164 -0
  37. package/src/sections-rows.ts +128 -0
  38. package/src/sidebar-render.ts +535 -0
  39. package/src/sidebar.ts +919 -0
  40. package/src/status.ts +1161 -0
  41. package/src/store.ts +279 -0
  42. package/src/tool-design-adoption.ts +263 -0
  43. package/src/tool-design-rows.ts +246 -0
  44. package/src/tool-design-wiring.ts +428 -0
  45. package/src/tool-designs.ts +402 -0
  46. package/src/transform-pipeline.ts +351 -0
  47. package/src/types.ts +442 -0
  48. package/src/visibility.ts +154 -0
  49. package/test/api.test.ts +395 -0
  50. package/test/apply-on-move.test.ts +477 -0
  51. package/test/arm.test.ts +220 -0
  52. package/test/bridge-ready.test.ts +309 -0
  53. package/test/bus.test.ts +165 -0
  54. package/test/button-rows.test.ts +409 -0
  55. package/test/capture.test.ts +356 -0
  56. package/test/card-style-rows.test.ts +510 -0
  57. package/test/card-style-transform.test.ts +379 -0
  58. package/test/card-style-wiring.test.ts +258 -0
  59. package/test/card-styles-e2e.test.ts +491 -0
  60. package/test/card-styles.test.ts +324 -0
  61. package/test/card-transcript-path.test.ts +178 -0
  62. package/test/config.test.ts +93 -0
  63. package/test/e2e.test.ts +443 -0
  64. package/test/example-bash-identity.test.ts +372 -0
  65. package/test/example-bash-matrix.test.ts +856 -0
  66. package/test/example-designs.test.ts +734 -0
  67. package/test/example-neon-transform.test.ts +283 -0
  68. package/test/example-plain-style.test.ts +1268 -0
  69. package/test/extension.test.ts +160 -0
  70. package/test/fake-events.ts +58 -0
  71. package/test/fake-host.ts +211 -0
  72. package/test/fake-sidebar.ts +516 -0
  73. package/test/fake-transcript.ts +486 -0
  74. package/test/frame-telemetry.test.ts +417 -0
  75. package/test/hook.test.ts +196 -0
  76. package/test/manifest.test.ts +105 -0
  77. package/test/new-category.test.ts +979 -0
  78. package/test/notices.test.ts +328 -0
  79. package/test/plugin-integration-lines.test.ts +979 -0
  80. package/test/plugin-surface.test.ts +152 -0
  81. package/test/presence.test.ts +459 -0
  82. package/test/rail-surfaces.test.ts +457 -0
  83. package/test/rail-width.test.ts +423 -0
  84. package/test/regression.test.ts +483 -0
  85. package/test/repaint-api.test.ts +200 -0
  86. package/test/reserved-categories.test.ts +361 -0
  87. package/test/row-labels.test.ts +284 -0
  88. package/test/row-names.test.ts +473 -0
  89. package/test/row-origin.test.ts +395 -0
  90. package/test/section-interaction.test.ts +364 -0
  91. package/test/section-panel.test.ts +403 -0
  92. package/test/sections-rows.test.ts +291 -0
  93. package/test/sidebar-backend.test.ts +471 -0
  94. package/test/sidebar-e2e.test.ts +331 -0
  95. package/test/sidebar-hover.test.ts +276 -0
  96. package/test/sidebar-independent.test.ts +349 -0
  97. package/test/sidebar-render.test.ts +281 -0
  98. package/test/sidebar-sections.test.ts +245 -0
  99. package/test/status-card-styles.test.ts +201 -0
  100. package/test/status-section-frame.test.ts +181 -0
  101. package/test/status-sections.test.ts +243 -0
  102. package/test/status.test.ts +209 -0
  103. package/test/store.test.ts +192 -0
  104. package/test/surface-repaint-cost.test.ts +520 -0
  105. package/test/tool-design-chain.test.ts +483 -0
  106. package/test/tool-design-cost.test.ts +473 -0
  107. package/test/tool-design-rows.test.ts +359 -0
  108. package/test/tool-designs.test.ts +745 -0
  109. package/test/transform-pipeline.test.ts +465 -0
  110. package/test/transforms-bus.test.ts +233 -0
  111. package/test/verifier-width-regressions.test.ts +164 -0
  112. package/tsconfig.json +18 -0
package/src/arm.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Arm lifecycle (S3 bullet 1, S4): one entry point that picks a backend and arms it.
3
+ *
4
+ * Pi keeps ONE `ctx.ui` object per runner but rebuilds it on every bind
5
+ * (`runner.ts:895-898`, `:573`, `:577-587`), so the bridge arms from `session_start` — possibly
6
+ * more than once per process, and against a fresh UI object each rebind. Arming is therefore
7
+ * idempotent per UI object: the capture backend marks the target it wrapped, and the hook backend
8
+ * marks the host registry it pushed into.
9
+ *
10
+ * Backend choice, in order:
11
+ * 1. the host registry (`installHookBackend`) when it exists and its shape is usable — nothing is
12
+ * patched, which is the whole reason to prefer it;
13
+ * 2. otherwise the proven capture backend. A present-but-unusable registry is reported as
14
+ * `fallback-capture` with `reason: "hook-unusable"` instead of being ignored.
15
+ *
16
+ * Nothing here runs at import time: no backend arms itself, so importing the module (or the
17
+ * package entry that will call it) has no side effect on the host.
18
+ */
19
+
20
+ import { disableCaptureBackend, installCaptureBackend } from "./capture.ts";
21
+ import { installHookBackend } from "./hook.ts";
22
+
23
+ /** Which interception is active after arming. */
24
+ export type ArmBackend = "hook" | "capture" | "none";
25
+
26
+ /**
27
+ * What arming decided. `status` (S6) reports this verbatim, so every branch that ends in "no rows
28
+ * appeared" has a name here.
29
+ */
30
+ export type ArmOutcome =
31
+ | "hooked"
32
+ | "capture-armed"
33
+ | "already-armed"
34
+ | "fallback-capture"
35
+ | "no-ui";
36
+
37
+ export type ArmReason = "hook-unusable" | "ui-missing" | "custom-missing";
38
+
39
+ export interface ArmResult {
40
+ outcome: ArmOutcome;
41
+ backend: ArmBackend;
42
+ /** Present when nothing armed cleanly, or when arming fell back to the capture backend. */
43
+ reason?: ArmReason;
44
+ }
45
+
46
+ /**
47
+ * Arm the bridge against a `ctx.ui`-shaped object and report what happened. Safe to call on every
48
+ * `session_start`, and safe to call with a missing UI context (reported, never thrown).
49
+ */
50
+ export function armBridge(ui: unknown): ArmResult {
51
+ const hook = installHookBackend();
52
+ if (hook.installed) {
53
+ // An earlier arm may have wrapped a UI object that is still live. Disable the previous path
54
+ // before the hook becomes active, so one menu cannot receive two copies of the rows.
55
+ disableCaptureBackend();
56
+ return { outcome: "hooked", backend: "hook" };
57
+ }
58
+
59
+ // Any present-but-unusable registry, or any failed installation into one, gets the proven
60
+ // backend rather than silence. Only "no registry at all" is a clean capture arm.
61
+ const fallback: ArmReason | undefined = hook.outcome === "no-hook" ? undefined : "hook-unusable";
62
+ const capture = installCaptureBackend(ui);
63
+
64
+ if (capture.armed) {
65
+ return fallback === undefined
66
+ ? { outcome: "capture-armed", backend: "capture" }
67
+ : { outcome: "fallback-capture", backend: "capture", reason: fallback };
68
+ }
69
+
70
+ if (capture.outcome === "already-armed") {
71
+ return fallback === undefined
72
+ ? { outcome: "already-armed", backend: "capture" }
73
+ : { outcome: "already-armed", backend: "capture", reason: fallback };
74
+ }
75
+
76
+ // Nothing to wrap: distinguish "no UI context at all" from "a UI without a customize surface".
77
+ const hasUi = ui !== null && typeof ui === "object";
78
+ return {
79
+ outcome: "no-ui",
80
+ backend: "none",
81
+ reason: hasUi ? "custom-missing" : "ui-missing",
82
+ };
83
+ }
package/src/bus.ts ADDED
@@ -0,0 +1,558 @@
1
+ /**
2
+ * The process-global bus: providers (docs/architecture.md §7, docs/contract.md §1, §5), sidebar
3
+ * sections (the sidebar spec's S1, S2) and transcript transforms (this feature's S1).
4
+ *
5
+ * Pi loads every extension through its own jiti loader with `moduleCache: false`, so module
6
+ * scope is per-copy and cannot hold shared state. The registration lists therefore live on
7
+ * `globalThis` behind a stable `Symbol.for` key, exactly as gentle-shell does for its card
8
+ * style (`lib/shell-card.ts:40-41`). Three providers loaded from three copies of this package
9
+ * see one bus.
10
+ *
11
+ * Sections share this slot rather than taking one of their own: both surfaces are registrations
12
+ * that must be process-global for the same reason, and one slot means one place to read (and to
13
+ * reset) what this process has contributed. `bus()` grows the `sections` list on a slot created
14
+ * by a copy that predates it, so a provider list already in flight is kept. The same holds for
15
+ * `cardStyles` and `transforms`.
16
+ *
17
+ * The key is the bridge's own; it is NOT the hook registry key
18
+ * `Symbol.for("gentle-pi.customize-row-providers")`, which belongs to the host (S4/T5), and it
19
+ * is not the host's rail key `Symbol.for("gentle-pi.experimental-sidebar.state")`.
20
+ *
21
+ * This module is internal. `src/index.ts` re-exports `registerRows` and `snapshotProviders`
22
+ * but never `resetBusForTests`; the section equivalents stay internal until the public surface is
23
+ * decided.
24
+ */
25
+
26
+ import { HOST_CARD_STYLE_VALUES, RESERVED_CARD_STYLE_IDS } from "./card-style-line.ts";
27
+ import { attributeRegistration } from "./plugin-lines.ts";
28
+ import { cardStyleRowLabelText, sectionRowLabelText } from "./row-labels.ts";
29
+ import {
30
+ clearRowNameRejections,
31
+ rejectRowName,
32
+ reservedCategory,
33
+ RESERVED_CATEGORY_RULE,
34
+ } from "./row-names.ts";
35
+ import type {
36
+ CardStyleRegistration,
37
+ RowProvider,
38
+ SidebarSection,
39
+ TranscriptTransform,
40
+ } from "./types.ts";
41
+
42
+ /** Stable process-global slot for the bridge's registration lists. */
43
+ const BUS_KEY = Symbol.for("gentle-shell-bridge.providers");
44
+
45
+ interface BusEntry {
46
+ provider: RowProvider;
47
+ }
48
+
49
+ interface SectionEntry {
50
+ section: SidebarSection;
51
+ }
52
+
53
+ interface CardStyleEntry {
54
+ style: CardStyleRegistration;
55
+ }
56
+
57
+ interface TransformEntry {
58
+ transform: TranscriptTransform;
59
+ }
60
+
61
+ interface BusState {
62
+ /** Providers in subscription order; one entry per live `id`. */
63
+ entries: BusEntry[];
64
+ /** Sections in registration order; one entry per live `id`. Ordering happens in the snapshot. */
65
+ sections: SectionEntry[];
66
+ /** Card styles in registration order; one entry per live `id`. Ordering: `snapshotCardStyles`. */
67
+ cardStyles: CardStyleEntry[];
68
+ /** Transcript transforms in registration order; ordering happens in `snapshotTransforms`. */
69
+ transforms: TransformEntry[];
70
+ }
71
+
72
+ /** The slot shape on `globalThis`; keeps the cast in one place. */
73
+ function busHost(): Record<symbol, BusState | undefined> {
74
+ // SAFETY: the only writes to this key are the `bus()` creation below, so IF this key holds
75
+ // anything it holds a BusState. A foreign or partial value is not trusted anyway: `bus()`
76
+ // re-checks `entries` and replaces an unusable slot before returning it.
77
+ return globalThis as unknown as Record<symbol, BusState | undefined>;
78
+ }
79
+
80
+ /**
81
+ * Return the process-global bus, creating it on first use. A pre-existing value that is not a
82
+ * usable bus (another library's slot, a partial object across reloads) is replaced rather than
83
+ * trusted.
84
+ */
85
+ function bus(): BusState {
86
+ const host = busHost();
87
+ const existing = host[BUS_KEY];
88
+ if (existing !== undefined && Array.isArray(existing.entries)) {
89
+ // A slot created by a copy of this package (or an earlier load) that predates sections keeps
90
+ // its live providers and gains the section list in place, so two copies always agree.
91
+ if (!Array.isArray(existing.sections)) existing.sections = [];
92
+ // Same for card styles: an older copy's bus gains the list instead of being replaced, so a
93
+ // style registered through one copy is visible to every other (S12, determinism).
94
+ if (!Array.isArray(existing.cardStyles)) existing.cardStyles = [];
95
+ // And for transforms: a copy that predates the pipeline keeps its live registrations and gains
96
+ // the list, so the wrapper another copy installed sees what this copy registered.
97
+ if (!Array.isArray(existing.transforms)) existing.transforms = [];
98
+ return existing;
99
+ }
100
+ const created: BusState = { entries: [], sections: [], cardStyles: [], transforms: [] };
101
+ host[BUS_KEY] = created;
102
+ return created;
103
+ }
104
+
105
+ /**
106
+ * Register a provider and return a disposer that removes exactly that provider.
107
+ *
108
+ * - Providers keep subscription order.
109
+ * - Registering an existing `id` replaces the previous registration *in place*, so the order
110
+ * is stable and there is still exactly one entry (and one diagnostics entry) for that id.
111
+ * - The disposer removes only the registration it created: after a same-id replacement, the
112
+ * replaced provider's disposer is a no-op instead of removing the replacement, and calling a
113
+ * disposer twice does nothing the second time.
114
+ */
115
+ export function registerRows(provider: RowProvider): () => void {
116
+ // Attribution is a wrapper, not part of the store: the plugin gets ONE INTEGRATIONS line from any
117
+ // number of registrations (`./plugin-lines.ts`, I1), and `registerRowsStored` keeps doing exactly
118
+ // what it did — including throwing a plugin's invalid id back at it.
119
+ return attributeRegistration("rows", provider, undefined, () => registerRowsStored(provider));
120
+ }
121
+
122
+ /**
123
+ * `Keymaps` is the host's own interaction (S15), not one of its setting pages: its rows capture the
124
+ * next key press, write `keymaps.json` and get a category footer that promises `Enter assign`
125
+ * (`lib/keymaps-customize.ts:49-68`, `lib/visual-customize-view.ts:40`). A plugin row there would be
126
+ * advertised as an assignable binding and could never honour it, so the page is reserved and the
127
+ * provider is refused BEFORE anything is stored.
128
+ *
129
+ * The throw is the refusal: `attributeRegistration` is the boundary every registration already goes
130
+ * through, so the refusal lands in the plugin's INTEGRATIONS line as a rejected registration (the
131
+ * same line, and the same `degraded` state, as an invalid id) and in `/bridge-status`. Nothing is
132
+ * stored, so a refused provider can never reach a menu build. A row that carries the reserved
133
+ * category ITSELF is refused per row at build time instead, so its siblings survive (`./rows.ts`).
134
+ */
135
+ function assertRowProviderCategory(provider: RowProvider): void {
136
+ const reserved = reservedCategory(provider.category);
137
+ if (reserved === undefined) return;
138
+ throw new Error(
139
+ `gentle-shell-bridge: cannot register row provider ${JSON.stringify(provider.id)}: its ` +
140
+ `category ${JSON.stringify(String(provider.category))} is the reserved category ` +
141
+ `${JSON.stringify(reserved)}. ${reserved} ${RESERVED_CATEGORY_RULE}. Place the rows in one of ` +
142
+ `the host's other categories, e.g. "Cards".`,
143
+ );
144
+ }
145
+
146
+ function registerRowsStored(provider: RowProvider): () => void {
147
+ assertRowProviderCategory(provider);
148
+ const state = bus();
149
+ const entry: BusEntry = { provider };
150
+ const index = state.entries.findIndex((candidate) => candidate.provider.id === provider.id);
151
+ if (index === -1) state.entries.push(entry);
152
+ else state.entries[index] = entry;
153
+
154
+ let disposed = false;
155
+ return () => {
156
+ if (disposed) return;
157
+ disposed = true;
158
+ const current = state.entries.indexOf(entry);
159
+ if (current !== -1) state.entries.splice(current, 1);
160
+ };
161
+ }
162
+
163
+ /**
164
+ * An ordered snapshot of the registered providers. The array is a fresh copy, so a fan-out
165
+ * that iterates it sees a stable list even if a provider registers or disposes mid-iteration.
166
+ */
167
+ export function snapshotProviders(): RowProvider[] {
168
+ return bus().entries.map((entry) => entry.provider);
169
+ }
170
+
171
+ /**
172
+ * Test-only seam: empties the process-global bus in place. Deliberately not re-exported from
173
+ * `src/index.ts`, so it is unreachable from the package's public entry.
174
+ */
175
+ export function resetBusForTests(): void {
176
+ bus().entries.length = 0;
177
+ }
178
+
179
+ /**
180
+ * Register a sidebar section and return a disposer that removes exactly that section.
181
+ *
182
+ * Same entry discipline as `registerRows`, because a plugin author should not have to learn two:
183
+ *
184
+ * - A second registration with an existing `id` replaces the previous one *in place*, so the
185
+ * section keeps the position its registration earned (ordering is computed in
186
+ * `snapshotSections`, so a replacement that changes `order` moves the section on the next read).
187
+ * - The returned disposer removes only the registration it created: after a same-id replacement the
188
+ * replaced section's disposer is a no-op instead of removing the replacement, and calling a
189
+ * disposer twice does nothing the second time.
190
+ *
191
+ * The disposer removes the section from the rail and from `Sections` together, because both surfaces
192
+ * are built from this one registration.
193
+ */
194
+ /** Ids that collide with `Object.prototype` and therefore cannot be used as a plain-object key. */
195
+ const RESERVED_REGISTRATION_IDS = new Set(["__proto__", "constructor", "prototype"]);
196
+
197
+ /**
198
+ * An id is identity: it keys the ordering, the replacement and the persisted value. A bad id is a
199
+ * plugin bug and is rejected with a clear error BEFORE anything is stored, so a rejected
200
+ * registration can never half-register (B2). One rule for every surface, so a plugin author does
201
+ * not have to learn two.
202
+ */
203
+ function registrationIdProblem(id: unknown): string | undefined {
204
+ if (typeof id !== "string") return "it must be a non-empty string";
205
+ if (id.trim() === "") return "it must be a non-empty string";
206
+ if (RESERVED_REGISTRATION_IDS.has(id)) return "it collides with Object.prototype";
207
+ if (id.includes("\0")) return "it contains a NUL byte";
208
+ return undefined;
209
+ }
210
+
211
+ /** Throws the surface-specific message; `kind` names the thing in both the message and the rule. */
212
+ export function assertRegistrationId(kind: string, id: unknown): asserts id is string {
213
+ const problem = registrationIdProblem(id);
214
+ if (problem === undefined) return;
215
+ throw new Error(
216
+ `gentle-shell-bridge: invalid ${kind} id ${JSON.stringify(id)}: ${problem}. ` +
217
+ `A ${kind} id must be a non-empty string that can be used as a plain object key.`,
218
+ );
219
+ }
220
+
221
+ function assertSectionId(id: unknown): asserts id is string {
222
+ assertRegistrationId("section", id);
223
+ }
224
+
225
+ /**
226
+ * `rowLabel`, when present, is text a human reads in the menu, so an empty one is a plugin bug and
227
+ * is rejected before anything is stored — the same discipline as the id.
228
+ */
229
+ function assertRowLabel(kind: string, id: string, rowLabel: unknown): void {
230
+ if (rowLabel === undefined) return;
231
+ if (typeof rowLabel !== "string" || rowLabel.trim() === "") {
232
+ throw new Error(
233
+ `gentle-shell-bridge: invalid ${kind} rowLabel for ${JSON.stringify(id)}: it must be a ` +
234
+ "non-empty string. It is the text a user reads in the menu; the id stays internal. Omit it " +
235
+ "to let the bridge derive a label from the visible text instead.",
236
+ );
237
+ }
238
+ }
239
+
240
+ export function registerSection(section: SidebarSection): () => void {
241
+ return attributeRegistration("section", section, "section", () => registerSectionStored(section));
242
+ }
243
+
244
+ function registerSectionStored(section: SidebarSection): () => void {
245
+ assertSectionId(section.id);
246
+ assertRowLabel("section", section.id, section.rowLabel);
247
+ // The label is what a user reads, so it obeys the row-name shape before anything is stored (N8):
248
+ // `Music: now playing` is rejected here, reported by `/bridge-status`, and never rewritten. A
249
+ // DERIVED label cannot fail this check — derivation applies the same shape (`./row-labels.ts`).
250
+ if (rejectRowName("section", section.id, "row label", sectionRowLabelText(section))) {
251
+ return () => {};
252
+ }
253
+ // The name in force now conforms, so a record left by the name it replaced is no longer true.
254
+ clearRowNameRejections("section", section.id);
255
+ const state = bus();
256
+ const entry: SectionEntry = { section };
257
+ const index = state.sections.findIndex((candidate) => candidate.section.id === section.id);
258
+ if (index === -1) state.sections.push(entry);
259
+ else state.sections[index] = entry;
260
+
261
+ let disposed = false;
262
+ return () => {
263
+ if (disposed) return;
264
+ disposed = true;
265
+ const current = state.sections.indexOf(entry);
266
+ if (current !== -1) state.sections.splice(current, 1);
267
+ };
268
+ }
269
+
270
+ /** A section whose `order` is a usable sort key; `NaN`, `Infinity` and a missing value are not. */
271
+ function sortableOrder(section: SidebarSection): number | undefined {
272
+ return typeof section.order === "number" && Number.isFinite(section.order)
273
+ ? section.order
274
+ : undefined;
275
+ }
276
+
277
+ /**
278
+ * The rendering order: sections with an `order` first, ascending, then those without, in
279
+ * registration order.
280
+ *
281
+ * Both halves are computed from the registration list, never from `Map` iteration: the sort is on
282
+ * explicit keys with the registration index as the tiebreak, so equal `order`s and unordered
283
+ * sections are deterministic. The array is a fresh copy, so a fan-out that iterates it sees a
284
+ * stable list even if a section registers or disposes mid-iteration.
285
+ */
286
+ export function snapshotSections(): SidebarSection[] {
287
+ const ordered: Array<{ section: SidebarSection; order: number; index: number }> = [];
288
+ const rest: SidebarSection[] = [];
289
+
290
+ bus().sections.forEach((entry, index) => {
291
+ const order = sortableOrder(entry.section);
292
+ if (order === undefined) rest.push(entry.section);
293
+ else ordered.push({ section: entry.section, order, index });
294
+ });
295
+
296
+ ordered.sort((a, b) => a.order - b.order || a.index - b.index);
297
+ return [...ordered.map((entry) => entry.section), ...rest];
298
+ }
299
+
300
+ /**
301
+ * Test-only seam for the section list only, so a provider test and a section test do not clear
302
+ * each other's registrations. Like `resetBusForTests`, it is deliberately not re-exported from
303
+ * `src/index.ts`.
304
+ */
305
+ export function resetSectionsForTests(): void {
306
+ bus().sections.length = 0;
307
+ }
308
+
309
+ /**
310
+ * A card style's `order`, when it has one that can be sorted on. `NaN`, `Infinity` and a missing
311
+ * value are not sort keys: a style with one of those keeps its registration position.
312
+ */
313
+ function sortableCardStyleOrder(style: CardStyleRegistration): number | undefined {
314
+ return typeof style.order === "number" && Number.isFinite(style.order) ? style.order : undefined;
315
+ }
316
+
317
+ /** A style's `frame` must be exactly what the transformer calls, or the first render would throw. */
318
+ function assertCardFrame(id: string, frame: unknown): void {
319
+ if (frame === null || typeof frame !== "object" || Array.isArray(frame)) {
320
+ throw new Error(
321
+ `gentle-shell-bridge: invalid card style ${JSON.stringify(id)}: frame must be an object ` +
322
+ "with top, line and bottom functions.",
323
+ );
324
+ }
325
+ for (const member of ["top", "line", "bottom"] as const) {
326
+ if (typeof (frame as Record<string, unknown>)[member] !== "function") {
327
+ throw new Error(
328
+ `gentle-shell-bridge: invalid card style ${JSON.stringify(id)}: frame.${member} must be a ` +
329
+ "function. A style draws chrome and nothing else (S2).",
330
+ );
331
+ }
332
+ }
333
+ }
334
+
335
+ function assertCardStyleLabel(id: string, label: unknown): void {
336
+ if (typeof label !== "string" || label.trim() === "") {
337
+ throw new Error(
338
+ `gentle-shell-bridge: invalid card style ${JSON.stringify(id)}: label must be a non-empty ` +
339
+ "string. It is the text the user reads on the Card style line, so keep it short.",
340
+ );
341
+ }
342
+ }
343
+
344
+ /**
345
+ * The label is what the user sees, so two styles showing the same text cannot be told apart (S13).
346
+ *
347
+ * Rejected before anything is stored: an equal label of another REGISTERED style (the same id
348
+ * re-registering keeps its own label, because it replaces itself) and any of the host's own values,
349
+ * which the host renders verbatim on the same line. The comparison is exact — case-sensitive, the
350
+ * way the host displays the string — so `cute` and `Cute` are two different visible values.
351
+ */
352
+ function assertCardStyleLabelIsDistinct(style: CardStyleRegistration): void {
353
+ if ((HOST_CARD_STYLE_VALUES as readonly string[]).includes(style.label)) {
354
+ throw new Error(
355
+ `gentle-shell-bridge: invalid card style label ${JSON.stringify(style.label)} for ` +
356
+ `${JSON.stringify(style.id)}: it is one of the host's own values ` +
357
+ `(${HOST_CARD_STYLE_VALUES.join(", ")}), so the Card style line would show it twice. ` +
358
+ "Pick a distinct label.",
359
+ );
360
+ }
361
+ const clash = snapshotCardStyles().find(
362
+ (candidate) => candidate.id !== style.id && candidate.label === style.label,
363
+ );
364
+ if (clash !== undefined) {
365
+ throw new Error(
366
+ `gentle-shell-bridge: invalid card style label ${JSON.stringify(style.label)} for ` +
367
+ `${JSON.stringify(style.id)}: it is already used by ${JSON.stringify(clash.id)}. Two values ` +
368
+ "reading the same cannot be told apart on the Card style line. Pick a distinct label.",
369
+ );
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Register a card style and return a disposer that removes exactly that style (S1, S12).
375
+ *
376
+ * Same entry discipline as providers and sections, because a plugin author should not have to learn
377
+ * two:
378
+ *
379
+ * - A second registration with an existing `id` replaces the previous one IN PLACE, so the style
380
+ * keeps the position its registration earned and there is still exactly one value on the line.
381
+ * Two plugins cannot both own `loon.cute`: the later registration wins.
382
+ * - The returned disposer removes only the registration it created: after a same-id replacement the
383
+ * replaced style's disposer is a no-op instead of removing the replacement, and calling a
384
+ * disposer twice does nothing the second time.
385
+ * - Validation happens BEFORE anything is stored (id, label, both frames), so a rejected style
386
+ * never half-registers and never reaches a menu build.
387
+ */
388
+ export function registerCardStyle(style: CardStyleRegistration): () => void {
389
+ return attributeRegistration("card style", style, "card style", () =>
390
+ registerCardStyleStored(style),
391
+ );
392
+ }
393
+
394
+ function registerCardStyleStored(style: CardStyleRegistration): () => void {
395
+ assertRegistrationId("card style", style.id);
396
+ if (RESERVED_CARD_STYLE_IDS.has(style.id)) {
397
+ throw new Error(
398
+ `gentle-shell-bridge: invalid card style id ${JSON.stringify(style.id)}: it is already a ` +
399
+ "value of the Card style line (the host's own neon/float, or the bridge's native), so the " +
400
+ "line would show the same value twice. Pick an id of your own, e.g. \"loon.cute\".",
401
+ );
402
+ }
403
+ assertCardStyleLabel(style.id, style.label);
404
+ assertCardStyleLabelIsDistinct(style);
405
+ assertRowLabel("card style", style.id, style.rowLabel);
406
+ // Same rule, same place in the sequence: a row label that breaks the row-name shape is rejected
407
+ // before the style is stored, and `/bridge-status` reports the string and the rule (N8).
408
+ if (rejectRowName("card style", style.id, "row label", cardStyleRowLabelText(style))) {
409
+ return () => {};
410
+ }
411
+ clearRowNameRejections("card style", style.id);
412
+ assertCardFrame(style.id, style.frame);
413
+
414
+ const state = bus();
415
+ const entry: CardStyleEntry = { style };
416
+ const index = state.cardStyles.findIndex((candidate) => candidate.style.id === style.id);
417
+ if (index === -1) state.cardStyles.push(entry);
418
+ else state.cardStyles[index] = entry;
419
+
420
+ let disposed = false;
421
+ return () => {
422
+ if (disposed) return;
423
+ disposed = true;
424
+ const current = state.cardStyles.indexOf(entry);
425
+ if (current !== -1) state.cardStyles.splice(current, 1);
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Every registered style, in the order its values appear on the line (S12): styles with an `order`
431
+ * first, ascending, then those without, in registration order.
432
+ *
433
+ * Both halves are computed from the registration list, never from `Map` iteration: the sort is on
434
+ * explicit keys with the registration index as the tiebreak, so equal `order`s and unordered styles
435
+ * are deterministic across sessions and across package copies. The array is a fresh copy, so a
436
+ * caller cannot mutate the registry and a fan-out iterating it sees a stable list even if a style
437
+ * registers or disposes mid-iteration.
438
+ */
439
+ export function snapshotCardStyles(): CardStyleRegistration[] {
440
+ const ordered: Array<{ style: CardStyleRegistration; order: number; index: number }> = [];
441
+ const rest: CardStyleRegistration[] = [];
442
+
443
+ bus().cardStyles.forEach((entry, index) => {
444
+ const order = sortableCardStyleOrder(entry.style);
445
+ if (order === undefined) rest.push(entry.style);
446
+ else ordered.push({ style: entry.style, order, index });
447
+ });
448
+
449
+ ordered.sort((a, b) => a.order - b.order || a.index - b.index);
450
+ return [...ordered.map((entry) => entry.style), ...rest];
451
+ }
452
+
453
+ /**
454
+ * Test-only seam for the card style list only, so a style test and a section test do not clear each
455
+ * other's registrations. Like `resetBusForTests`, it is deliberately not re-exported from
456
+ * `src/index.ts`.
457
+ */
458
+ export function resetCardStylesForTests(): void {
459
+ bus().cardStyles.length = 0;
460
+ }
461
+
462
+ /**
463
+ * A transform's `order`, when it has one that can be sorted on. `NaN`, `Infinity` and a missing
464
+ * value are not sort keys: a transform with one of those keeps its registration position.
465
+ */
466
+ function sortableTransformOrder(transform: TranscriptTransform): number | undefined {
467
+ return typeof transform.order === "number" && Number.isFinite(transform.order)
468
+ ? transform.order
469
+ : undefined;
470
+ }
471
+
472
+ /**
473
+ * The transform member must be exactly what the pipeline calls, checked before anything is stored:
474
+ * a registration the first frame would throw on is a plugin bug, not a frame's problem.
475
+ */
476
+ function assertTransformFunction(id: string, transform: unknown): void {
477
+ if (typeof transform !== "function") {
478
+ throw new Error(
479
+ `gentle-shell-bridge: invalid transform ${JSON.stringify(id)}: transform must be a function `
480
+ + `of (lines, ctx) that returns the lines to draw. A transform rewrites rows; the bridge `
481
+ + `clips them, isolates its throw and reports it in /bridge-status (S1).`,
482
+ );
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Register a transcript transform and return a disposer that removes exactly that transform (S1).
488
+ *
489
+ * Same entry discipline as providers, sections and card styles, because a plugin author should not
490
+ * have to learn two:
491
+ *
492
+ * - A second registration with an existing `id` replaces the previous one IN PLACE, so the
493
+ * transform keeps the position its registration earned (ordering is computed in
494
+ * `snapshotTransforms`, so a replacement that changes `order` moves the transform on the next
495
+ * read). Two plugins cannot both own `loon.restyle`: the later registration wins.
496
+ * - The returned disposer removes only the registration it created: after a same-id replacement the
497
+ * replaced transform's disposer is a no-op instead of removing the replacement, and calling a
498
+ * disposer twice does nothing the second time.
499
+ * - Validation happens BEFORE anything is stored (id, `transform`), so a rejected transform never
500
+ * half-registers and never reaches a frame.
501
+ */
502
+ export function registerTransform(transform: TranscriptTransform): () => void {
503
+ return attributeRegistration("transform", transform, undefined, () =>
504
+ registerTransformStored(transform),
505
+ );
506
+ }
507
+
508
+ function registerTransformStored(transform: TranscriptTransform): () => void {
509
+ assertRegistrationId("transform", transform.id);
510
+ assertTransformFunction(transform.id, transform.transform);
511
+
512
+ const state = bus();
513
+ const entry: TransformEntry = { transform };
514
+ const index = state.transforms.findIndex((candidate) => candidate.transform.id === transform.id);
515
+ if (index === -1) state.transforms.push(entry);
516
+ else state.transforms[index] = entry;
517
+
518
+ let disposed = false;
519
+ return () => {
520
+ if (disposed) return;
521
+ disposed = true;
522
+ const current = state.transforms.indexOf(entry);
523
+ if (current !== -1) state.transforms.splice(current, 1);
524
+ };
525
+ }
526
+
527
+ /**
528
+ * Every registered transform, in pipeline order (S2): transforms with an `order` first, ascending,
529
+ * then those without, in registration order.
530
+ *
531
+ * Both halves are computed from the registration list, never from `Map` iteration: the sort is on
532
+ * explicit keys with the registration index as the tiebreak, so equal `order`s and unordered
533
+ * transforms are deterministic across sessions and across package copies. The array is a fresh
534
+ * copy, so a frame iterating it sees a stable list even if a transform registers or disposes
535
+ * mid-frame.
536
+ */
537
+ export function snapshotTransforms(): TranscriptTransform[] {
538
+ const ordered: Array<{ transform: TranscriptTransform; order: number; index: number }> = [];
539
+ const rest: TranscriptTransform[] = [];
540
+
541
+ bus().transforms.forEach((entry, index) => {
542
+ const order = sortableTransformOrder(entry.transform);
543
+ if (order === undefined) rest.push(entry.transform);
544
+ else ordered.push({ transform: entry.transform, order, index });
545
+ });
546
+
547
+ ordered.sort((a, b) => a.order - b.order || a.index - b.index);
548
+ return [...ordered.map((entry) => entry.transform), ...rest];
549
+ }
550
+
551
+ /**
552
+ * Test-only seam for the transform list only, so a transform test and a section test do not clear
553
+ * each other's registrations. Like `resetBusForTests`, it is deliberately not re-exported from
554
+ * `src/index.ts`.
555
+ */
556
+ export function resetTransformsForTests(): void {
557
+ bus().transforms.length = 0;
558
+ }