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
@@ -0,0 +1,1266 @@
1
+ # Contract — `registerRows`, providers, rows, diagnostics
2
+
3
+ What a plugin can rely on. The mechanism behind it is in
4
+ [architecture.md](architecture.md); the persistence decision in [state.md](state.md).
5
+
6
+ ## 1. `registerRows(provider): () => void`
7
+
8
+ ```ts
9
+ const dispose: () => void = registerRows(provider);
10
+ ```
11
+
12
+ Registers a row provider with the process-global bridge and returns a disposer
13
+ unsubscribing exactly that provider — no other provider's rows and no bridge state are
14
+ touched. It is safe to call from `session_start`, including after a rebind: the bridge
15
+ arms once, and a provider registered twice under the same `id` replaces its previous
16
+ registration instead of stacking rows.
17
+
18
+ Registration does **not** open the menu. Rows are built when a menu is actually
19
+ constructed.
20
+
21
+ The package's public entry exports exactly `registerRows`, `registerSection`, `createStore`,
22
+ `STORE_SCHEMA_VERSION`, `VERSION`, the runtime helpers `bridgeApi`, `isBridgeApi`, `onBridgeApi`,
23
+ the announcement channel `BRIDGE_READY_EVENT` (§9), and
24
+ the row/provider/section/store/API types. `snapshotProviders`, `snapshotSections`,
25
+ `publishBridgeApi`, `announceBridgeReady` and every `…ForTests` seam are **internal**: they exist so
26
+ the bridge's own backends and tests can drive one process, and they are not part of this
27
+ contract.
28
+
29
+ ## 2. Provider
30
+
31
+ ```ts
32
+ interface RowProvider {
33
+ id: string; // unique, stable, e.g. "loon.banner-source"
34
+ category: string; // a category gentle-shell already renders
35
+ name?: string; // optional: this plugin's INTEGRATIONS row (§8)
36
+ rows(ctx: RowContext): CustomizeRow[]; // evaluated lazily, per menu build
37
+ }
38
+ ```
39
+
40
+ - `rows()` runs each time the menu is built, never at registration. That is what makes
41
+ `current()` honest: the plugin reads its live state at build time, so the bullet matches
42
+ reality before the user touches anything.
43
+ - `ctx` carries at least the active theme and the resolved gentle-shell config home. The
44
+ theme is the one Pi passes to the factory (`factory(this.ui, theme, this.keybindings,
45
+ close)`, `interactive-mode.ts:3136`); the config home is the directory gentle-shell
46
+ resolves for its own files (default `~/.pi/gentle-ai`, `GENTLE_PI_CONFIG_HOME` override —
47
+ `lib/gentle-shell-bundled.ts:231-239`), so a plugin can place per-user state without
48
+ re-deriving the path.
49
+ - `id` is the handle used by ordering, replacement and the diagnostics report. Two
50
+ providers may share an `id` only by replacing each other; two providers with different
51
+ `id`s but the same `category` + `setting` conflict (see §6).
52
+
53
+ ## 3. Row fields
54
+
55
+ All of these are already understood by gentle-shell's renderer
56
+ (`lib/visual-customize-view.ts:107-128`); the bridge carries them through unchanged, with one
57
+ deliberate exception: it sets `choice.applyOnMove: false` on every row it contributes (§4).
58
+
59
+ | Field | Type | Behaviour |
60
+ | --- | --- | --- |
61
+ | `category` | string (existing category) | which menu page the row joins |
62
+ | `label` | `string \| (() => string)` | row text; a function is evaluated when the menu is built |
63
+ | `choice` | `{ setting, value, current(), hint?, applyOnMove? }` | one value of a setting; consecutive rows of the same `setting` share a line. The bridge forces `applyOnMove: false` (§4) |
64
+ | `preview()` | `{ title, sample }` | read-only depiction of the highlighted choice, never an application |
65
+ | `visible()` | `() => boolean` | hidden when false; a throw fails closed (row hidden) |
66
+ | `keyhint` | string | footer hint for this row instead of the category default |
67
+ | `action(inline)` | `(inline) => void \| Promise<void>` | Enter/Space handler; may be async |
68
+
69
+ Inline helpers available to `action` (`lib/visual-customize-view.ts:129-140`):
70
+
71
+ - `inline.input({ prompt, value })` — inline field on the highlighted row; resolves the
72
+ submitted string or `undefined` on cancel.
73
+ - `inline.confirm(message)` — inline confirmation; resolves `true` only after an explicit,
74
+ fully visible yes.
75
+ - `inline.capture(prompt)` — next raw key press; resolves the key or `undefined` on Esc.
76
+
77
+ They exist so a row never opens a nested native dialog; a plugin that needs a value should
78
+ use them rather than `ctx.ui.input`.
79
+
80
+ ## 4. How a toggle works
81
+
82
+ A toggle is not a widget type. Push two consecutive rows sharing the same `choice.setting`
83
+ with different `value`s:
84
+
85
+ ```ts
86
+ const toggle = (value: string) => ({
87
+ category: "Layout" as const,
88
+ label: () => "Loon banner source",
89
+ choice: { setting: "Loon banner source", value, current: () => read() === value },
90
+ action: () => store.set("bannerSource", value),
91
+ });
92
+
93
+ rows: () => [toggle("activity"), toggle("output")], // one rendered line
94
+ ```
95
+
96
+ The renderer draws one line, `Name ● current ○ other`, and handles the keys itself: ←/→ moves
97
+ the cursor inside the line and applies a value only when that row does not defer, Enter applies
98
+ whatever is focused (`lib/visual-customize-view.ts:552-577`). "Consecutive" is literal — a row
99
+ between the two values splits them into two lines.
100
+
101
+ ### Moving never applies: every bridge row defers to Enter
102
+
103
+ A line the bridge contributes is navigated, not switched under the user's fingers: ←/→ moves the
104
+ `›` cursor and Enter applies. That is one row field, so it is one rule for every category:
105
+
106
+ - every row the bridge itself builds — the `Sections` toggle, the card-style values in both modes
107
+ (the host's `Card style` line extended in place, and the fallback `Bridge card style` line)
108
+ including `native`, and every value of a tool design's `Cards` line — carries
109
+ `choice.applyOnMove: false`;
110
+ - `buildRows` enforces it for a plugin's own provider rows too, by shallow-copying the row and its
111
+ `choice` (the provider's objects are never mutated, and every other field is the same reference).
112
+ An authored `applyOnMove: true` is overridden: the bridge owns the integration and the design is
113
+ one behaviour for every row it adds, so the field has no author-facing meaning here;
114
+ - the host's OWN rows are untouched. `Card style · neon/float` still applies on ←/→ because the
115
+ host wrote it that way, so a line holding both behaves per row — which is exactly the host's rule.
116
+
117
+ While such a row is focused the host's footer reads `←/→ choose · Enter apply` instead of
118
+ `←/→ change` (`lib/visual-customize-view.ts:35-38,751`), and moving costs nothing: no store write,
119
+ no persisted value, no repaint request. Enter runs `action()` exactly once, which is where the
120
+ bridge's write path (persist first, repaint second) lives.
121
+
122
+ Other row kinds follow from the same shape:
123
+
124
+ - **Command row** — no `choice`, only `action`: one line, Enter runs it.
125
+ - **Gated row** — `visible()` returns `false` (or throws) and the row disappears.
126
+ - **Preview row** — `preview()` supplies the read-only headline and sample.
127
+ - **Inline editor row** — `action` opens `inline.input()`, asks with `inline.confirm()`, or
128
+ captures a key with `inline.capture()`.
129
+
130
+ ### Two kinds of row: toggle and button
131
+
132
+ A row is one of two first-class kinds. A **toggle** has a `choice`; consecutive rows sharing
133
+ `choice.setting` render as one line. A **button** has a `label` and an `action` and NO `choice`; Enter
134
+ runs the action, the shape of gentle-shell's own `Profiles` and `Reset` rows. A button's label is its
135
+ whole visible text, so the row-name rule below applies to it exactly as to a toggle's label (one to three
136
+ plain words, no structural marks; refused and reported, never rewritten). `preview`, `keyhint` and
137
+ `visible` pass through untouched, and the bridge injects neither a `choice` nor an `applyOnMove`. Its
138
+ one change is the `action`, wrapped so a throw or a rejected promise is contained and recorded once in
139
+ `/bridge-status` under `notices`, while the menu and every other row stay usable; the provider's object
140
+ is never mutated, so the host receives a copy.
141
+
142
+ ### Row names
143
+
144
+ `choice.setting` (the line's name) and `label` (the row's text) are what a human reads, so both obey
145
+ ONE shape: **one to three plain words**, title case like the host's own rows (`Card style`,
146
+ `Card content`), and **none of** `→ ← ↑ ↓ : . | / \ [ ] ( ) { }`. No trailing qualifier — the
147
+ category already says where the row lives, and the values already say what the choices are:
148
+
149
+ ```text
150
+ Bash design OK a row name: the host's own vocabulary
151
+ Bash card → Bash rendering BAD arrow and a repeated qualifier: reads as a breadcrumb
152
+ Bash card: rendering BAD colon
153
+ Section: bash-card.status BAD prefix plus a dotted id
154
+ ```
155
+
156
+ The bridge enforces the shape instead of trusting authors; it is never a silent rewrite:
157
+
158
+ - a provider row whose line name or label breaks it is **dropped from the menu build** — absent
159
+ from the rows the host receives AND from the rows `/bridge-status` compares for duplicates, so it
160
+ is not part of the effective set;
161
+ - a section or a card style whose `rowLabel` breaks it is **not registered at all**;
162
+ - the offending string is recorded verbatim with the rule it broke, and `/bridge-status` lists it
163
+ under `row rejections`. The menu never shows a sanitized version of a name the bridge refused,
164
+ so the exact string in the report is the one to fix.
165
+
166
+ Derived names (a section `title`, a card style `label`) cannot violate the shape: derivation drops
167
+ every non-word character — marks included — and clamps the result to the same word limit. The
168
+ clamp is the only transformation the bridge performs, and only on text it derives itself.
169
+
170
+ ## 5. Ordering and idempotency
171
+
172
+ - Providers apply in subscription order; their rows are appended in that order, so a
173
+ deterministic provider set gives a deterministic menu.
174
+ - Disposing one provider leaves the others' relative order intact.
175
+ - Arming is idempotent across `session_start`, `/reload` and double loads: one interception
176
+ layer, one injection per captured row array (non-enumerable marker on the array), one
177
+ wrapper per UI object (non-enumerable marker on the target).
178
+ - Registering the same `id` twice yields one provider's rows and one diagnostics entry.
179
+
180
+ ## 6. Conflicts
181
+
182
+ Same `category` + same `setting` from two different providers has one of two outcomes,
183
+ determined by insertion order:
184
+
185
+ - **Consecutive** in the captured array → the renderer coalesces them into a single line
186
+ whose bullets are shared, with the two providers' `action`s behind the same setting
187
+ (`lib/visual-customize-view.ts:395-401`).
188
+ - **Not consecutive** → two separate lines in a category, each with its own bullets, which
189
+ reads as a duplicate to the user.
190
+
191
+ Recommended policy: **pick a distinct plain-word name**, e.g. `"Loon banner source"` rather than
192
+ `"Banner source"`. Namespacing with punctuation (`"Loon → Banner source"`) is not an option: the
193
+ row-name shape forbids arrows and colons, and the bridge rejects such a name before it is stored
194
+ (see §4, "Row names"). The bridge warns on duplicate `category` + `setting` pairs and reports them
195
+ through `/bridge-status`, comparing the rows of the last menu build (`no build yet` before the first
196
+ build); it does not rename rows, because a row's setting name is its identity in the renderer.
197
+
198
+ ## 7. Failure isolation
199
+
200
+ A provider that throws while its rows are built loses its rows and nothing else:
201
+
202
+ - the menu still opens, with the other providers' rows;
203
+ - the failure is recorded as the last failure for `/bridge-status`, with the provider `id` and the
204
+ error message;
205
+ - the remaining providers still apply, in order.
206
+
207
+ The same applies to an exception inside a row's `visible()` (the row is hidden) and to a
208
+ rejection from `action()`. Both sync throws and async rejections are routed by the view's
209
+ `withBusy()` to `options.onError` (`lib/visual-customize-view.ts:224-235`), which
210
+ gentle-shell already wires to an error notification; the menu stays usable.
211
+
212
+ ## 8. Diagnostics: `/bridge-status`
213
+
214
+ One command, `/bridge-status`, reporting:
215
+
216
+ | Field | Meaning |
217
+ | --- | --- |
218
+ | Providers | every registered provider, `id` and `category`, in subscription order |
219
+ | Backend | which interception backend is active: `hook` or `capture` |
220
+ | Last capture | outcome of the most recent menu build, with the number of rows injected |
221
+ | Last failures | most recent provider failures: `id`, message, and whether rows were dropped |
222
+ | Store problems | store files this process could not use, with the plugin id and the reason |
223
+ | Duplicate settings | every `category` + `setting` pair contributed by more than one provider in the rows of the last menu build; `no build yet` before the first build |
224
+ | Sections | every registered section with its id, title, the `row` label the menu reads (and the collision note when two wanted the same label), persisted visibility (plus `collapsed` while the card is folded) and where its position comes from (`order 3` or `registration order`); omitted when no section is registered. Ids stay here on purpose: this report is for the plugin author, not the user picking a toggle |
225
+ | Section frame | which drawing the section header and rows use: `host` when the host's own `renderPanelHeader`/`renderPanelRow` were found on an object the bridge is handed, `fallback` otherwise — with what was looked for, whether Pi has handed the widget factory a theme, and (for `fallback`) a `section frame gap` line naming what cannot be reached. Emitted with the sections table |
226
+ | Section interaction | how the pointer reaches the rows: the host's own `handleMouse` dispatch, and how the hover claim is carried (`hover owner claimed on the host's sidebar state`, `unavailable` with the reason, or `not used yet`). Emitted with the sections table |
227
+ | Section failures | the most recent sidebar frame's failing sections with `id`, `phase` (`lines`/`meta`/`onClick`) and message |
228
+ | Sidebar | `intercepted (rail wrapped)`, `intercepted (rail pending)`, `no-sidebar-state`, or `not armed` with the reason the wiring could not acquire a `tui` |
229
+ | Transforms | every registered transcript transform in pipeline order (`order 3` or `registration order`) with the last frame's outcome: `applied`, `skipped`, `failed` with the message, or `not run`; omitted when no transform is registered |
230
+ | Card style surfaces | per surface (`conversation`, `rail`): whether the selected style reached it and what its last frame did there (`transformed N cards`, `pass-through (native / missing / no card)`, `failed` with the style and message); `not reached` carries the attach outcome (`no-layout-root`, `no-sidebar`, `no-rail`, …). Emitted with the card-style block |
231
+ | Last repaint | the most recent repaint a bridge-owned write requested: the surfaces, the cause (`section "loon.music" → hidden`, `card style → loon.cute`) and `repainted` or `unavailable` with the reason. Omitted until such a write happens |
232
+ | Row name rejections | every line name and row label the bridge refused (`rows`, `section` or `card style`), with the surface, the registration `id`, the field (`line name`/`row label`), the offending string VERBATIM and the rule it broke; omitted when nothing was rejected |
233
+ | Tool designs | every tool with designs: the selection in force (`native` or a design id, `<id> (missing: not registered)` when it no longer exists), whether the one resolver has mounted a card for it (`wrapper mounted on 2 cards` / `wrapper not mounted`), how many cards the post-construction adoption pass reached for it (`adopted 1 card`, S10), each registered design's id, label and description; then the resolver's last outcome and how many designs it registered, and any conflicts or failures. Omitted when the process registered no design |
234
+ | Plugin lines | one entry per plugin that registered (`rows`, `section`, `card style`, `transform` or `tool design`): its row name and whether it was `derived from package "@acme/loon-kit"` or an `explicit name`, the registrations `accepted` and `rejected`, any later `name conflict` that declared a different name for the same plugin (with the name kept), and whether the line is `set` or, with the reason, `no line set`. The header also says when the host's `setStatus` was unavailable. Omitted when no plugin registered so far |
235
+
236
+ A menu that opened without the bridge's rows must show up here — armed-but-uncaptured,
237
+ shape mismatch, or provider failure. A registered section that never appears must show up
238
+ too: the sections table proves the registration, and the sidebar line proves whether the rail
239
+ was ever reached. Diagnostics never run a provider's `rows()` and never run a section's
240
+ `lines()` or `meta()`: both are UI, and a report is not a frame.
241
+
242
+ ### The plugin's INTEGRATIONS line
243
+
244
+ `ctx.ui.setStatus(key, text)` paints one row in the host's INTEGRATIONS card, and `undefined`
245
+ removes it. The bridge sets exactly **one** row per plugin, no matter how many rows, sections,
246
+ card styles or transforms that plugin registers (one plugin, one line).
247
+
248
+ - **The text is opaque.** The host renders the whole string as one row; it is not parsed into a
249
+ key and a value, so everything the row says must be inside it: `Loon kit · ready`. The host's
250
+ `sanitizeStatus` strips ANSI sequences and collapses whitespace, so **ANSI is not yours** and a
251
+ glyph must be part of the text itself (the host's own fixture row is `🧠 gentle-pi · ready`).
252
+ - **Colour belongs to the host.** Every entry is painted with one shared status role. A plugin
253
+ cannot choose a colour; it can only choose a glyph.
254
+ - **The name is derived, or given once.** The bridge captures the module that registered, resolves
255
+ it to its owning `package.json` and derives one to three plain words from its `name`
256
+ (`@acme/loon-kit` → `Loon kit`). The answer is cached per module. A module with no owning package
257
+ (a single copied file) gets NO row, and `/bridge-status` says why. The Pi installation itself is
258
+ never a plugin identity: a module owned by the package that provides `pi` (the package that owns
259
+ the running `pi` entry point, or one whose manifest declares the `pi` executable) fails closed the
260
+ same way, so a shared extensions directory cannot produce a `Pi coding agent` row.
261
+ - **Identity is the plugin, and the name is sticky.** A plugin identity is its owning package root
262
+ (every module in that package is the same plugin), or the registering module when no package owns
263
+ it. It is resolved once and cached; the footer key is hashed from that identity, never from the
264
+ display name, so a rename — of the package or of the name — repaints the SAME key and leaves no
265
+ orphan row. Any registration kind accepts an optional `name`: the first registration that declares
266
+ a usable one fixes the name for the identity, and later registrations inherit it whether or not
267
+ they repeat it. A later DIFFERENT name does not rename and never adds a row; the first name is
268
+ kept and the conflict is reported in `/bridge-status`. A name is attribution, not mutable state;
269
+ a deliberate rename ships as an edit plus `/reload`, a new registration lifetime.
270
+ - **The name obeys the row-name shape** (§4, Row names). A derived name is cleaned; when nothing
271
+ usable is left there is no row. An explicit name is validated, never rewritten: a rejected name
272
+ produces no row at all (when it is the first registration's), and a later rejected name is
273
+ reported without discarding the name the plugin already has.
274
+ - **The state is derived, never promised.** `ready` means every registration the bridge accepted;
275
+ one rejected registration (an invalid id, a row label that broke the shape rule) repaints the same
276
+ key as `degraded`, and this report names the rejected items.
277
+ - **One key, painted in place.** A plugin's key is stable for its lifetime; every later change
278
+ repaints that same key, and `session_shutdown` clears it with `undefined`. A `ctx.ui` without
279
+ `setStatus` skips the whole feature and is reported here instead of throwing into a registration.
280
+
281
+ The row name is the diagnostic column's label; the key and the module path stay in this report,
282
+ because it is read by the plugin author, not by the user reading the footer.
283
+
284
+ ## 9. Reaching the bridge — `session_start`, or the announcement
285
+
286
+ **The rule: register at `session_start` if the package loaded first, otherwise when the
287
+ announcement arrives.** Pi loads every extension module and waits for its async initialization
288
+ before it fires `session_start` (`docs/extensions.md:213`), and it fires that event for `startup`,
289
+ `reload`, `new`, `resume` and `fork` (`:393`). But it does not guarantee that THIS package loaded
290
+ before the plugin: global extensions (`~/.pi/agent/extensions`) load — and receive `session_start` —
291
+ before a configured package's module publishes. A real TUI showed exactly that, with the plugin's
292
+ handler finding no symbol while the bridge went on to publish a moment later.
293
+
294
+ So an absent symbol at `session_start` is **"not yet"**, not "not installed". The symbol stays the
295
+ only authority for *which* object to use; the announcement below is how a plugin learns that a
296
+ later publication happened. The bridge publishes its object as its module loads, and the process is
297
+ told once, on Pi's cross-extension bus.
298
+
299
+ ### What is published
300
+
301
+ `Symbol.for("gentle-shell-bridge/api/v1")` holds exactly:
302
+
303
+ ```ts
304
+ Object.freeze({
305
+ schema: "gentle-shell-bridge/api", // identity, so a foreign payload is never mistaken for ours
306
+ version: 1, // the major; a breaking change is a NEW symbol key
307
+ VERSION: "0.3.0", // the publishing release; informational only
308
+ registerRows,
309
+ registerSection,
310
+ registerCardStyle, // since 0.2.0; optional on an older published object
311
+ registerTransform, // since 0.3.0; optional on an older published object
312
+ registerToolDesign, // since 0.3.0; optional on an older published object
313
+ requestRepaint, // since 0.3.0; optional on an older published object
314
+ createStore,
315
+ });
316
+ ```
317
+
318
+ Publication is **first wins**: a compatible object already in the slot is kept and never replaced,
319
+ and the slot is never reset. A consumer gates on the schema string, `version === 1` and the three
320
+ callable members below — `registerCardStyle`, `registerTransform`, `registerToolDesign` and
321
+ `requestRepaint` are NOT part of the gate, so an object published by an older copy is still a usable
322
+ bridge for rows, sections and the store; a plugin that needs a later surface checks
323
+ `typeof api.registerCardStyle === "function"` (or `registerTransform`, `registerToolDesign`,
324
+ `requestRepaint`) before calling it. Anything else — another library's
325
+ payload, another major, a partial object — is
326
+ **absent**, never an error: the object is not used, the slot is left exactly as it is (never
327
+ overwritten, deleted or reset), and one notice per process names the mismatch. `v1` in the key is
328
+ the major: a breaking change is a new `v2` key, never a mutation of this one.
329
+
330
+ ### Two ways to read it
331
+
332
+ Read the symbol directly — no import, the form the worked example uses. The `session_start` attempt
333
+ is kept, and the announcement covers a bridge that publishes after this file loaded:
334
+
335
+ ```ts
336
+ const KEY = Symbol.for("gentle-shell-bridge/api/v1");
337
+ const isApi = (value: any): boolean =>
338
+ typeof value === "object" && value !== null &&
339
+ value.schema === "gentle-shell-bridge/api" && value.version === 1 &&
340
+ typeof value.registerRows === "function" &&
341
+ typeof value.registerSection === "function" &&
342
+ typeof value.createStore === "function";
343
+
344
+ const register = (): boolean => {
345
+ const api = (globalThis as any)[KEY];
346
+ if (!isApi(api)) return false;
347
+ // api.registerRows(...) / api.registerSection(...) / api.createStore(...)
348
+ return true;
349
+ };
350
+
351
+ const registered = register();
352
+ if (!registered) pi.events?.on("gentle-shell-bridge:ready", register);
353
+
354
+ pi.on("session_start", (_event, ctx) => {
355
+ if (!register()) {
356
+ ctx.ui?.notify(
357
+ "my-plugin: waiting for gentle-shell-bridge to announce itself",
358
+ "warning",
359
+ );
360
+ }
361
+ });
362
+ ```
363
+
364
+ Or import the package and call `onBridgeApi(handler, pi.events)`, which does both forms for you: the
365
+ handler runs immediately when the object is already published (and no listener is taken), otherwise
366
+ it runs when the announcement arrives, once, after which the subscription removes itself.
367
+
368
+ ```ts
369
+ import { onBridgeApi, type BridgeApi } from "gentle-shell-bridge";
370
+
371
+ const register = (api: BridgeApi): void => {
372
+ // api.registerRows(...) / api.registerSection(...) / api.createStore(...)
373
+ };
374
+
375
+ // Works in both orders: this package loaded first, or the plugin did.
376
+ const dispose = onBridgeApi(register, pi.events);
377
+ ```
378
+
379
+ `bridgeApi()` never waits, defers or retries: it answers about the slot as it is right now.
380
+
381
+ ### The announcement: `gentle-shell-bridge:ready`
382
+
383
+ When this package publishes its object, it emits one event on Pi's cross-extension bus
384
+ (`docs/extensions.md:1965-1972`), the same channel gentle-shell's other extensions use:
385
+
386
+ ```ts
387
+ pi.events.on("gentle-shell-bridge:ready", (payload) => {
388
+ // { schema: "gentle-shell-bridge/api", version: 1, VERSION: "0.3.0" }
389
+ });
390
+ ```
391
+
392
+ - **Name:** `gentle-shell-bridge:ready`. Payload: `{ schema, version, VERSION }` — the same identity,
393
+ major and release the published object carries, frozen. Informational only: the payload says
394
+ *which* bridge announced itself, never *what* it can do.
395
+ - **Emitted once per process**, after a successful publish, no matter how many copies of the package
396
+ Pi loaded (`moduleCache: false` gives it one module instance per extension, not one per process).
397
+ - **Never for an incompatible slot.** If the symbol holds another library's payload or another major,
398
+ nothing is announced: there is no object to send a listener to, and `bridgeApi()` stays
399
+ `undefined`.
400
+ - **Silent without a bus.** On a Pi with no `pi.events`, nothing is emitted and nothing is reported;
401
+ the symbol still works for any plugin that reads it.
402
+ - **It is a notification, not a registry.** The listener still reads the symbol and applies the gate:
403
+ an announcement that is not backed by a compatible object is ignored. That is why the event can
404
+ never hand out a half-working API.
405
+
406
+ The order that matters: the announcement may arrive before or after the plugin's `session_start`,
407
+ and registration is idempotent by stable id, so a plugin may register from both paths.
408
+
409
+ ### Surface 1: rows for a customize category
410
+
411
+ One provider contributes rows to a category the host already renders. A toggle is two
412
+ **consecutive** rows sharing one `choice.setting`:
413
+
414
+ ```ts
415
+ const store = api.createStore("loon");
416
+ const read = () => store.get("bannerSource", "activity");
417
+
418
+ const toggle = (value: string) => ({
419
+ category: "Cards" as const,
420
+ label: () => "Loon banner source",
421
+ choice: {
422
+ setting: "Loon banner source",
423
+ value,
424
+ current: () => read() === value, // live: read at render time, so the ● never lies
425
+ },
426
+ preview: () => ({ title: value, sample: "literal depiction of this choice" }),
427
+ action: () => store.set("bannerSource", value), // persist FIRST; the host repaints after
428
+ });
429
+
430
+ const dispose = api.registerRows({
431
+ id: "loon.banner-source",
432
+ category: "Cards",
433
+ rows: () => [toggle("activity"), toggle("output")], // consecutive rows → one line
434
+ });
435
+ ```
436
+
437
+ Nothing may sit between the two values or the renderer draws two lines. `current()` is evaluated
438
+ when the menu is built, `action()` runs before the host's own repaint, and `preview()` replaces the
439
+ host's "Preview unavailable" default. `dispose()` removes exactly this provider's rows.
440
+
441
+ ### Surface 2: a sidebar section
442
+
443
+ One call adds the card to gentle-shell's fullscreen sidebar **and** its show/hide toggle to
444
+ `/gentle:customize` → `Sections`. The plugin writes content only; it never names a `setting`, a
445
+ value, the `Sections` category or the rail:
446
+
447
+ ```ts
448
+ const dispose = api.registerSection({
449
+ id: "loon.music", // unique and stable: ordering, replacement, the toggle
450
+ title: "MUSIC", // card heading, drawn by the host's own frame
451
+ rowLabel: "music", // optional: what the Sections row reads (derived from title when absent)
452
+ meta: () => "1 playing", // optional right-aligned text
453
+ lines: () => ["track Blue in Green", "album Kind of Blue"], // per frame, live
454
+ defaultVisible: true, // absent means true
455
+ order: 10, // optional integer
456
+ });
457
+ ```
458
+
459
+ The bridge generates the `Sections` rows for it: two consecutive rows sharing one label,
460
+ values `shown` / `hidden`, with a live `current()` and a persist-first `action()`. The label — not
461
+ the id — is the line's name: `music ● shown ○ hidden`, next to the host's own
462
+ `stats ● shown ○ hidden`. A hidden section is not rendered and its `lines()`/`meta()` are not
463
+ called. A throw loses only that section's body and is reported by `/bridge-status`. `dispose()`
464
+ removes the card and the toggle together, leaving every other section untouched.
465
+
466
+ ### Surface 3: the optional namespaced store
467
+
468
+ `createStore(pluginId)` is optional. A plugin may keep its own file instead; the bridge never reads
469
+ or writes that file.
470
+
471
+ ```ts
472
+ const store = api.createStore("loon");
473
+ store.set("bannerSource", "activity");
474
+ const value = store.get("bannerSource", "activity"); // caller's fallback when absent or unreadable
475
+ ```
476
+
477
+ The document is `{ "schemaVersion": 1, "values": { … } }` at
478
+ `<gentle config home>/gentle-shell-bridge/<pluginId>.json`. `values` is an envelope, so a plugin
479
+ key cannot collide with `schemaVersion`. Writes are atomic: a temporary file in the same directory,
480
+ then a rename over the target. `pluginId` is rejected before any filesystem access unless it is one
481
+ non-empty path segment. A malformed file or an unknown `schemaVersion` reads as empty and is
482
+ reported by `/bridge-status`; it is not repaired or deleted, and a later `set` writes a valid
483
+ document. Details and the rejected alternatives: §10 and [state.md](state.md).
484
+
485
+ ### When the API is absent: the plugin decides
486
+
487
+ | What the handler sees | What it means | What the plugin may do |
488
+ | --- | --- | --- |
489
+ | `bridgeApi() === undefined`, or the direct read is `undefined`, **and no announcement arrived** | no bridge has published in this process (yet) | notify and continue, wait for `gentle-shell-bridge:ready`, or fail loudly — say which |
490
+ | the object fails the gate | another library's payload, another major, or a partial object | same as absent: never call it |
491
+ | a compatible object | the bridge is installed, with every surface above | register, then forget about load order |
492
+
493
+ Absent is still absent: `bridgeApi()` describes the slot as it is right now. What changed is the
494
+ conclusion a plugin draws from it — "not installed" is only honest when no announcement can arrive
495
+ (a Pi without `pi.events`, or a plugin that chose not to listen). The bridge cannot tell a plugin
496
+ what to do when it is absent, and does not try: notify-and-continue, a local fallback and a loud
497
+ failure are all legitimate, and only the plugin knows which one fits.
498
+
499
+ ### The compatibility cliff, on purpose
500
+
501
+ An older bridge publishes nothing at this key, so a newer plugin sees **absent** instead of being
502
+ deferred silently. A newer bridge and an older plugin meet the same way when the plugin's gate
503
+ demands a member the old object does not have. That is the intended answer: a plugin can always
504
+ tell the user it found no compatible bridge, and no registration can sit invisible in a holding
505
+ area waiting for a version that will never arrive.
506
+
507
+ ## 10. Persistence
508
+
509
+ The bridge composes rows; it does not own preferences. A plugin reads and writes its own
510
+ state. `createStore(pluginId)` is an **optional** helper for a namespaced file with atomic
511
+ writes and a schema version; a plugin may keep its own file instead, and the bridge never
512
+ reads or writes that file.
513
+
514
+ The document is `{ "schemaVersion": 1, "values": { … } }`. `values` is an envelope, so a
515
+ plugin key cannot collide with `schemaVersion`. The file lives at
516
+ `<bridge config home>/<pluginId>.json`, where `<bridge config home>` is `<gentle config
517
+ home>/gentle-shell-bridge`; `pluginId` is rejected before it reaches the filesystem unless
518
+ it is one non-empty path segment. A malformed file, or one whose `schemaVersion` is not `1`,
519
+ reads as empty and is reported by `/bridge-status`; the file is not repaired or deleted, and
520
+ a later `set` writes a valid document that carries over existing `values` entries while
521
+ dropping foreign top-level keys. Rationale and rejected alternatives: [state.md](state.md).
522
+
523
+ ## 11. Sidebar sections
524
+
525
+ One call adds a card to gentle-shell's fullscreen sidebar **and** its show/hide toggle to
526
+ `/gentle:customize` → `Sections`. The plugin writes content; it never writes a `setting`, a
527
+ `value`, a `current()`, an `action()`, a `preview()`, never names the `Sections` category and
528
+ never touches the rail.
529
+
530
+ ```ts
531
+ import { registerSection } from "gentle-shell-bridge";
532
+
533
+ const dispose: () => void = registerSection({
534
+ id: "loon.music", // unique and stable: ordering, replacement, the toggle
535
+ title: "MUSIC", // card heading, drawn like AGENTS / CHANGES / STATS
536
+ rowLabel: "music", // optional: the text the Sections row reads
537
+ meta: () => "1 playing", // optional right-aligned count; live, read per frame
538
+ lines: (ctx) => [ // body, evaluated per frame
539
+ "track Blue in Green", // a plain string: drawn byte for byte, as before
540
+ { // or a structured row, drawn like a native one
541
+ glyph: "▸",
542
+ label: "album",
543
+ meta: "Kind of Blue",
544
+ tone: "muted", // text | muted | dim | accent | success | error | warning
545
+ onClick: () => play(), // only a row with onClick is clickable
546
+ },
547
+ ],
548
+ defaultVisible: true, // absent means true
549
+ order: 10, // optional integer
550
+ });
551
+ ```
552
+
553
+ - `id` — registering an existing id **replaces** the previous section in place; a second
554
+ card never appears. It is the toggle's identity (replacement, disposer and the persisted
555
+ visibility key), and it is **internal**: it never appears in a row.
556
+ - `title` — the heading the host's own card frame draws. The bridge does not draw a box.
557
+ - `rowLabel` — optional: what the `Sections` row reads, e.g. `music`. When absent the bridge
558
+ derives one from `title`: lowercase words joined by spaces, dots and punctuation dropped, no
559
+ prefix, clamped to three words (`"BASH CARD"` → `"bash card"`, `"usageCost"` → `"usage cost"`,
560
+ `"MUSIC PLAYER NOW PLAYING"` → `"music player now"`). Two sections whose labels would read the
561
+ same are told apart deterministically (`music`, `music 2`, …) and `/bridge-status` says which one
562
+ moved. A `rowLabel` that breaks the row-name shape (one to three plain words, no arrow, colon,
563
+ dot, pipe, slash or bracket — see §4) is rejected: the section is not registered, and the
564
+ offending string is reported under `row rejections`. Nothing is rewritten.
565
+ - `meta(ctx)` — optional right-aligned text. Read per frame, so a value that changes is drawn on the
566
+ next frame without re-registering. A throw drops that section's body (see below).
567
+ - `lines(ctx)` — the body. Evaluated **per frame**, so live state is honest with no redraw API. A row
568
+ is a plain string or a structured row (see below).
569
+ - `defaultVisible` — the fallback until the user toggles the section.
570
+ - `order` — sections **with** an `order` render first, ascending; sections without one follow
571
+ in registration order. Ties keep registration order. The rule is computed from an explicit
572
+ sort key, never from map iteration.
573
+
574
+ `ctx` carries the same shape as `RowContext`: the resolved gentle-shell config home and, now that Pi
575
+ hands the bridge a theme (`ctx.ui.setWidget(key, (tui, theme) => …)`), the host theme — so a section
576
+ can paint with the same roles the rest of the shell uses. Before the widget factory runs, `theme` is
577
+ `undefined` and the card is drawn uncoloured; `/bridge-status` says which.
578
+
579
+ ### Body rows
580
+
581
+ A row is a plain **string** or a structured **row object**:
582
+
583
+ ```ts
584
+ { label: string; meta?: string; glyph?: string; tone?: Tone; onClick?: () => void }
585
+ ```
586
+
587
+ - a plain string is drawn **byte for byte**: the author's characters, clipped to the width, no
588
+ indent, no padding, no styling. Every section written before structured rows existed looks
589
+ exactly the same;
590
+ - a structured row is drawn the way the host draws its own `AGENTS` rows: indent, `glyph label` on
591
+ the left, the right-aligned dimmer `meta`, and the parts painted with the host's roles;
592
+ - `tone` names one of the host's own roles — `text`, `muted`, `dim`, `accent`, `success`, `error`,
593
+ `warning` — and never a colour or an SGR code. Anything else is **rejected**;
594
+ - `onClick` is what makes a row clickable. A row without it is **inert**: it never highlights under
595
+ the pointer and never answers a click;
596
+ - `meta` is read at render time, so the same registration can show a value that moves (a mode, a
597
+ count, an elapsed time) with no re-registration.
598
+
599
+ The bridge maps physical rows to row identity and answers the host's own pointer dispatch; clicking
600
+ an actionable row runs exactly one `onClick`, once per click. A throwing `onClick` is recorded (phase
601
+ `onClick`) and never escapes into the host.
602
+
603
+ ### Rendering
604
+
605
+ A section is drawn by the host's own card code: the bridge contributes the part's header and rows and
606
+ the card groups over them, and the layout calls `sectionCardRows` on them. The HEADER is the host's
607
+ header — caret from the section's own collapse state, uppercase title, right-aligned `meta`, the
608
+ host's hover accent.
609
+
610
+ Where those two builders come from is reported, not assumed. The host's own
611
+ `renderPanelHeader`/`renderPanelRow` live in gentle-shell's `lib/shell-panel.ts`, which this package
612
+ cannot import (Pi loads each extension in its own module graph). So the bridge **looks** for them on
613
+ every object it is handed — the rail part, the Pi theme, the terminal's sidebar state — and:
614
+
615
+ - when it finds them, it draws with them and `/bridge-status` reports `section frame: host`;
616
+ - otherwise it draws with a byte-identical port of the same algorithm (proved against the host's
617
+ code transcribed into `test/section-panel.test.ts`) and reports `section frame: fallback` plus the
618
+ gap, naming exactly what was looked for.
619
+
620
+ ## Width discipline on the rail (N17, N18)
621
+
622
+ The host decides the rail's width from the layout, never from a part's output: the rail is the second
623
+ entry of its fullscreen `hstack` with `basis: RAIL_WIDTH, grow: 0, shrink: 0`
624
+ (`lib/shell-sidebar-layout.ts:493-496`), so pi-tui allocates those 50 columns without measuring the
625
+ child, and the conversation gets `columns - 53`. One decision IS made from a part's rows:
626
+
627
+ ```text
628
+ const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
629
+ ```
630
+ (`lib/shell-sidebar-layout.ts:410`) — a single rail row wider than the content width drops the whole
631
+ rail, and the conversation then takes every column: the layout's widths changed because of what a
632
+ part returned.
633
+
634
+ **N17 — a contributed part never asks for more room than it was given.** Every row the bridge
635
+ contributes is at most the width the host handed the part, measured the way the host measures it:
636
+ `visibleWidth` (`pi-tui/dist/utils.js:212-268`) strips ANSI, expands a TAB to **three** columns
637
+ (`graphemeWidth`, `:148-155`) and charges each grapheme cluster as a whole (`:255-258`) — an emoji
638
+ cluster two columns, a cluster of marks none. `src/card-style-transform.ts`'s `hostColumns` ports
639
+ that model; `src/section-panel.ts` fits and pads with it, and a plain string row is clipped with it
640
+ instead of by UTF-16 length. Padding is counted the same way, so the pad never makes the host believe
641
+ the column wants to be wider.
642
+
643
+ **N18 — the host's own rule, matched.** The host's parts PAD: `renderPanelRow(width, left, right,
644
+ indent)` is `fitPanelLine(" " + left, right, width)`, and `fitPanelLine` pads `left`/`right` to
645
+ `width` (`lib/shell-panel.ts:31-40,78-80`). The bridge therefore pads identically — and to the
646
+ HOST-measured width, which is the whole of N17. A plain string row stays unpadded, byte for byte
647
+ (`§11`, N10).
648
+
649
+ **N19 — proved by a test, not by prose.** `test/rail-width.test.ts` models the host's rail path
650
+ (the fixed `hstack` basis, the one-column padding at `:414`, the `active` gate) and asserts that a
651
+ contributed section leaves the main column where the host put it and that no contributed row exceeds
652
+ the given width under the host's own measure — a CJK body row, a row containing a TAB, and an emoji
653
+ cluster included.
654
+
655
+ **N20 — visible without a screenshot.** `/bridge-status` prints `section width: <N> columns given,
656
+ widest row <M>` after the section frame block, from the last render's own record
657
+ (`getSectionFrameWidth()`); a session whose rail never rendered a section prints no such line.
658
+
659
+ A plain string row is clipped to the width the host passes and the body is capped at 8 rows with a
660
+ visible `… +N more` marker, measured in the host's columns. A structured row is fitted by the panel
661
+ builder, which measures **columns** the same way. The bridge never returns a row wider than that
662
+ number, so a wide CJK glyph, an emoji or a TAB can no longer slip past the clip.
663
+
664
+ **Sections render after the native ones. Interleaving is impossible**: the host's status
665
+ sections (`CHANGES`, `STATS`, `INTEGRATIONS`) are built in a closure the bridge cannot reach
666
+ (`lib/shell-bar.ts:247,251-316`), and the rail paints a hard-coded key order
667
+ (`lib/shell-sidebar-layout.ts:344`). The bridge therefore appends to the last painted part
668
+ (`footer`), so a section always follows every native card.
669
+
670
+ ### Collapsing
671
+
672
+ The header is the section's collapse control, as it is for the host's own cards: clicking it folds
673
+ the card. A collapsed section is its header alone — the body is **not built**, not built and hidden,
674
+ so `lines()` is not called while it is folded. The folded state is bridge-owned store state (the
675
+ plugin writes no flag and no setting for it), and a click persists it and asks for the frame in the
676
+ same call.
677
+
678
+ ### Live updates and the digest
679
+
680
+ `meta()` and `lines()` are read per frame, and the bridge folds what they produced into the rail
681
+ part's `digest`. The host's layout re-renders a part only when that value moves
682
+ (`lib/shell-sidebar-layout.ts:114-119`), so a row field that changed — a mode, a count, a hovered row
683
+ — reaches the screen with no re-registration and no timer. The host's own digest stays part of the
684
+ value; when nothing contributes rows, the host's answer is passed through unchanged.
685
+
686
+ ### Hidden means not built
687
+
688
+ A hidden section is not rendered **and** its `lines()`/`meta()` are not called. Hiding is a
689
+ user choice, not a request to compute something invisible.
690
+
691
+ ### Failure isolation
692
+
693
+ A throwing `lines()` or `meta()`, or a return that is not the documented shape, drops that
694
+ section's body, records `{ sectionId, phase, message }` for `/bridge-status`, and leaves every
695
+ native card and every other section rendering. A non-array, a sparse array, or a row that is not a
696
+ string and has no string `label` is a failure, never coerced; so is a `tone` outside the seven host
697
+ roles, a non-string `meta`/`glyph`, and an `onClick` that is not a function. A throwing `onClick` is
698
+ recorded with phase `onClick`.
699
+
700
+ ### The automatic `Sections` rows
701
+
702
+ The host's own `Sections` rows are bare labels: `changes`, `rdd`, `agents`, `todo`,
703
+ `usageCost`, `modelDetails`, `contextWarning`, `stats`. The bridge's rows read the same way — a
704
+ label the user reads, never an id and never a prefix:
705
+
706
+ ```text
707
+ stats ● shown ○ hidden ← the host's own row
708
+ music ● shown ○ hidden ← a plugin's section (id "loon.music", title "MUSIC")
709
+ ```
710
+
711
+ For every registered section the bridge emits **two consecutive rows** in category `Sections`,
712
+ sharing one label as their `choice.setting` (the host prints the setting as the line's name) with
713
+ values `shown` and `hidden`:
714
+
715
+ - the label is the author's `rowLabel`, or `title` made readable (lowercase, words joined, no dots,
716
+ no prefix, clamped to three words); the same string is the row `label` and the line's `setting`.
717
+ An author's `rowLabel` must satisfy the row-name shape (§4): one that does not is rejected, the
718
+ section is never registered, and the string and the rule appear under `row rejections`;
719
+ - `current()` reads the persisted value live, so the `●` never lies;
720
+ - `action()` persists **first** and returns; the host's own repaint re-reads `current()`;
721
+ - `preview()` returns a literal depiction of the card in that state, never the host's
722
+ "Preview unavailable" default.
723
+
724
+ **The id is internal, the label is public.** `id` keys the registry (replacement and ordering), the
725
+ `Sections`-row disposer and `bridge-sections.json`; it is never rendered. The label is the only
726
+ thing a user sees.
727
+
728
+ **Two sections never collide.** If two labels would read the same, the bridge disambiguates in
729
+ render order: the first keeps the label, the next gets the lowest free suffix (`music`, `music 2`,
730
+ `music 3`, …), never the raw id. The assignment is recomputed per menu build from the live
731
+ registry, so it is deterministic for a given set of registrations. `/bridge-status` reports the
732
+ label in force for each section and names the collision, so the menu and the report can never
733
+ disagree. A title with no letters or digits at all derives to `section` (the fallback word)
734
+ rather than an empty or id-shaped label.
735
+
736
+ Rows are built per menu build from the live registry: a section registered later appears
737
+ without re-registering anything, and a disposed section's rows disappear with it.
738
+
739
+ ### Persistence: the same experience, a different file
740
+
741
+ Visibility is **bridge-owned state**, not plugin state — the bridge added the toggle, so a
742
+ plugin needs no store of its own. It is persisted through the existing `createStore` helper in
743
+ one namespaced file:
744
+
745
+ `<gentle config home>/gentle-shell-bridge/bridge-sections.json`
746
+
747
+ ```json
748
+ {
749
+ "schemaVersion": 1,
750
+ "values": {
751
+ "sections": { "loon.music": false },
752
+ "collapsed": { "loon.music": true }
753
+ }
754
+ }
755
+ ```
756
+
757
+ `sections` maps a section id to `true` (shown) / `false` (hidden); a missing id falls back to
758
+ `defaultVisible`. `collapsed` maps a section id to `true` (folded) / `false` (open); a missing id is
759
+ OPEN, the state a card a user has never folded starts in. A value that is not a boolean is ignored
760
+ rather than coerced. Both maps are bridge-owned: the plugin writes neither a flag nor a setting.
761
+
762
+ Why not `visual-customization.json`: the host validates that file against a closed
763
+ `VISUAL_SECTION_KEYS` list (`lib/visual-customization-policy.ts:82`, `keysMatch` ~:112-130) and
764
+ rejects unknown keys, so a third-party section key cannot live there. The user experience is
765
+ identical to a native section toggle; the file deliberately is not. The blast radius is the
766
+ same per-plugin decision recorded in [state.md](state.md).
767
+
768
+ ## 12. Card styles
769
+
770
+ One call adds a card style to gentle-shell's conversation cards — a third option on the same
771
+ `Card style` line the host already draws, with no change to the host.
772
+
773
+ ```ts
774
+ import { registerCardStyle } from "gentle-shell-bridge";
775
+
776
+ const dispose: () => void = registerCardStyle({
777
+ id: "loon.cute", // identity: replacement and ordering key, and the stored value
778
+ label: "cute", // what the user reads; unique among the line's values
779
+ rowLabel: "cute", // optional: what this style's row reads (derived from label when absent)
780
+ order: 10, // optional: ordered styles first, ascending, then registration order
781
+ frame: { top, line, bottom }, // the three functions below; nothing else is called
782
+ preview: () => ({ title: "Cute", sample: "○ cute ╭─ welcome ─╮" }), // optional, literal
783
+ });
784
+ ```
785
+
786
+ The id never appears in a row: the host's line reads `Card style ○ neon ● float ○ cute`, and the
787
+ style's own row is labelled `cute` (or the author's `rowLabel`), exactly like a host row's visible
788
+ text. `label` stays the line's value — the text the host renders and the uniqueness rule applies to
789
+ — while `rowLabel` is what the menu row reads when it is not a setting line.
790
+
791
+ ### The frame contract
792
+
793
+ ```ts
794
+ top(ctx: { width: number; title?: string; hint?: string }): string;
795
+ line(ctx: { text: string; width: number; first: boolean }): string;
796
+ bottom(ctx: { width: number }): string;
797
+ ```
798
+
799
+ `width` is the width the host rendered at. `title` is the heading the host printed, without its
800
+ glyph and without the right-aligned hint; `hint` is that hint when its style runs prove it is one
801
+ (a subtitle is never passed as a hint, and when the split is not provable the whole remainder
802
+ arrives as `title` with `hint` undefined). `line` receives one body row at a time, `first` true for
803
+ the card's first row. The bridge extracts all of it and passes it in; a style parses nothing. A row
804
+ a style returns wider than `width` is clipped before it reaches the screen.
805
+
806
+ ### The host's own line is extended, never duplicated
807
+
808
+ At capture the bridge locates the host's `Card style` rows (category `Cards`, that `setting`),
809
+ splices them out and puts them back at the same index, immediately followed by one row per
810
+ registered style, all sharing the host's `setting`. The host coalesces consecutive rows that share a
811
+ setting, so the user sees ONE line in the host's own position:
812
+
813
+ ```text
814
+ Card style ○ neon ● float ○ cute
815
+ ```
816
+
817
+ The host's own rows keep the host's `current()` and `action()`, wrapped in exactly two ways:
818
+ `current()` reports `false` while a registered style is selected (one bullet at a time), and
819
+ `action()` also returns the bridge to native rendering (below). The bridge never reimplements
820
+ `neon`/`float` and never writes the host's `card-style.json`.
821
+
822
+ ### Which surfaces a style reaches
823
+
824
+ A style is one global choice, so it paints **both** surfaces the host frames cards on:
825
+
826
+ - **the conversation** — the chat component, whose `render(width)` the bridge wraps;
827
+ - **the rail** — the fullscreen sidebar, whose section cards the host frames itself
828
+ (`lib/shell-sidebar-layout.ts:36-47`) and whose framed rows the bridge repaints through the same
829
+ `transformCardLines` the conversation uses. Host sections (`CHANGES`, `AGENTS`, …) and the
830
+ sections a plugin contributed are restyled alike, because the rail frames them with one function.
831
+
832
+ `native` is the pass-through on both: the host's own frames reach the screen as the same array
833
+ instance the host produced. A plugin registers nothing extra for the rail — the style it already
834
+ declared is the whole change — and `/bridge-status` reports each
835
+ surface separately (`card style surface conversation: …`, `card style surface rail: …`) so "the
836
+ conversation changed but the sidebar did not" is answerable.
837
+
838
+ The rail is restyled only where the host already drew a `float` frame: a `neon` rail, a panel
839
+ surface, or any row the bridge cannot prove is a card passes through untouched.
840
+
841
+ ### A selection repaints, synchronously
842
+
843
+ Pressing a value writes the selection and then repaints, before the action returns: the host's own
844
+ `invalidateSidebar` step (its terminal-side cache revision) and `requestRender()`. A press that
845
+ selects the value already stored requests no frame. The same rule covers a section's shown/hidden
846
+ toggle, which repaints the rail. When no `tui` has been captured, or it has no `requestRender`, the
847
+ write still lands and `/bridge-status` reports `last repaint: … (unavailable: …)` — a write that
848
+ reached no surface is never silent.
849
+
850
+ ### The way back to host rendering
851
+
852
+ Pressing a host value (`neon`/`float`) **is** the way back: the host's own action runs, and the
853
+ bridge clears its selection to `native` in a `finally`, so even a throwing setter cannot leave the
854
+ session trapped inside a bridge style. The host's values are native rendering.
855
+
856
+ ### Fallback mode
857
+
858
+ If the host's line cannot be located (a renamed setting, a menu without `Cards`, no capture at
859
+ all), the bridge contributes its own line instead — `Bridge card style`, with `native` plus every
860
+ registered style. `native` means "the bridge leaves the conversation alone". `/bridge-status`
861
+ reports which mode is in force; the values are never dropped to fit a narrow terminal, and the
862
+ bridge does not control that width.
863
+
864
+ ### The selection
865
+
866
+ The selection is the bridge's state, not the plugin's:
867
+ `<gentle config home>/gentle-shell-bridge/bridge-card-style.json`.
868
+
869
+ ```json
870
+ { "schemaVersion": 1, "values": { "cardStyle": "loon.cute" } }
871
+ ```
872
+
873
+ `cardStyle` is a style id or `native`. A missing file, an unreadable one, an unknown
874
+ `schemaVersion` or a non-string value reads as `native`. A stored id whose style was disposed
875
+ resolves to **missing**, which behaves exactly like `native` — no chrome is drawn for a style that
876
+ no longer exists — and `/bridge-status` says `selection: … (missing: not registered)`. That is the
877
+ whole degrade rule: choosing a style is opt-in, and losing the plugin that owns it can never leave a
878
+ conversation broken.
879
+
880
+ ### What a registration must satisfy
881
+
882
+ - `id`: a non-empty string, not `__proto__`/`constructor`/`prototype`, no NUL byte, and not a value
883
+ the line already shows (`neon`, `float`, `native`).
884
+ - `label`: a non-empty string, unique — case-sensitively, as the host displays it — among the
885
+ line's values (another registered style's label or the host's own). Two values reading the same
886
+ cannot be told apart.
887
+ - `rowLabel`: optional; the text this style's row reads, used verbatim. Absent, it is derived from
888
+ `label` (lowercase words, no dots, no prefix, at most three words). The same row-name shape as
889
+ every other row applies (§4): one that breaks it is rejected — the style is not registered, and
890
+ the string and the rule are reported under `row rejections`. An empty one is rejected too.
891
+ The `id` is never a row label.
892
+ - `frame.top`, `frame.line`, `frame.bottom`: functions. Any failure throws **before** anything is
893
+ stored, like the row and section id rules.
894
+ - Registering an existing `id` replaces that style in place; `dispose()` removes exactly the one
895
+ registration it created, and calling it twice is a no-op.
896
+
897
+ ### What cannot be promised
898
+
899
+ - A later post-processor that rewrites the conversation — a component that re-frames the rows and
900
+ strips box chrome — can erase this frame. The bridge cannot prevent it, and
901
+ `/bridge-status` says so instead of pretending otherwise, naming the mechanism and no product.
902
+ - The repaint depends on the host's own `float` grammar (see
903
+ [architecture.md](architecture.md) §14): a card drawn some other way is left exactly as it is.
904
+ - A style never breaks the conversation: any throw inside detection or inside a style makes that
905
+ frame a pass-through and is reported.
906
+
907
+ ## 13. Transcript transforms
908
+
909
+ One call adds a function that sees the conversation's rows on **every frame** and returns the rows to
910
+ draw. It is the layer a rendering choice needs, because `pi.registerToolRenderer` resolves once, when
911
+ a tool row is BUILT: a choice made there can only reach the NEXT row, while a transform runs inside
912
+ the wrapper the bridge already installs on the conversation component
913
+ ([architecture.md](architecture.md) §15).
914
+
915
+ ```ts
916
+ import { registerTransform } from "gentle-shell-bridge";
917
+
918
+ const dispose: () => void = registerTransform({
919
+ id: "loon.gutter", // unique and stable: ordering, replacement, the /bridge-status name
920
+ order: 10, // optional: ordered transforms run first, ascending
921
+ transform: (lines, ctx) => {
922
+ const rows = lines.filter((line) => line.trim() !== "");
923
+ ctx.report?.({ // optional telemetry: counts only, never a line of the frame
924
+ candidates: lines.length,
925
+ recognised: rows.length,
926
+ rewritten: lines.length - rows.length,
927
+ });
928
+ return rows;
929
+ },
930
+ });
931
+ ```
932
+
933
+ ### What the transform receives
934
+
935
+ `lines` is the frame's rows: the host's own output, after the card-style repaint when a style is
936
+ selected (so a transform sees what the user would have seen), and after every transform that ran
937
+ before it — the pipeline is ordered, and each transform receives the previous one's output.
938
+ `ctx.width` is the width the host rendered at.
939
+
940
+ A transform receives LINES and nothing else: no tool name, no arguments, no result object. A plugin
941
+ that needs tool metadata uses `pi.registerToolRenderer` for rows yet to be built; a plugin that needs
942
+ a rendering choice to apply to rows ALREADY on screen uses a transform.
943
+
944
+ ### What the bridge guarantees
945
+
946
+ - **Order.** Transforms with an `order` run first, ascending, then the rest in registration order —
947
+ the rule sections and card styles already use. The sequence is snapshotted once per frame.
948
+ - **Width.** Whatever a transform returns is clipped to `ctx.width` with the same `clipToWidth` the
949
+ card styles use, so it cannot make the frame wider than the terminal. The measure and the cut are
950
+ the host's: a TAB is three columns, a grapheme cluster is charged as a whole and never split, and an
951
+ OSC-8 hyperlink contributes its text, not its URL. A row that already fits under that measure is
952
+ returned byte-identical, escapes included — a transform that did not touch a row must not cost the
953
+ host a byte. When a row IS cut, an OSC-8 link left open is closed and an active SGR is reset, so a
954
+ shortened row cannot leak a link or a paint into the rows after it; nothing is padded to fit.
955
+ - **Isolation.** A transform that throws, or that returns something which is not an array of
956
+ strings, loses only its own contribution for that frame: the lines in force are kept, the failure is
957
+ recorded with its id and message, and the remaining transforms still run. Nothing a transform does
958
+ can throw into Pi's render.
959
+ - **Identity.** With no transform registered the wrapper returns the host's own array instance, and a
960
+ transform that returns its input unchanged keeps it: a frame nothing changed is the same array.
961
+ - **Telemetry.** `ctx.report({ candidates, recognised, rewritten, shapes })` records what the
962
+ transform's own grammar did with the frame; `/bridge-status` prints it next to the outcome (§ below).
963
+
964
+ ### Frame telemetry: what a transform saw
965
+
966
+ The outcome alone cannot tell "the grammar recognised nothing" from "the frame never reached the
967
+ plugin" — the difference between a working pipeline and one that silently misses. So a transform
968
+ may report, per frame:
969
+
970
+ - `candidates`: the rows it examined as a possible card head;
971
+ - `recognised`: the candidates its grammar accepted;
972
+ - `rewritten`: the cards it actually rewrote on this frame (`0` when a setting leaves them alone);
973
+ - `shapes` (optional): which of its shapes matched, by name, and how many times.
974
+
975
+ A shape name is the transform's own word for a frame it knows; it never carries a line. A plugin
976
+ that reads `float` cards and the host's `neon` outline reports those two names, so a `/bridge-status`
977
+ line names the shape that matched instead of leaving the reader to guess.
978
+
979
+ The bridge adds the count of lines the transform received and stores the whole record as that
980
+ frame's entry — one record per transform, replaced by the next frame, never accumulated.
981
+
982
+ **Counts only.** A frame carries the user's private output, so nothing of it is stored or shown: no
983
+ line, no text, no plugin content. Every number is clamped to a non-negative integer; a shape name is
984
+ kept only when it IS a name — lowercase letters, digits, `_`, `.`, `-`, at most 24 characters — so a
985
+ space or an escape is dropped rather than recorded (never rewritten and shown); and at most 12 shape
986
+ names are kept per frame. A shape name lives in `/bridge-status` beside plugin ids, which is
987
+ diagnostics, never a row (§4).
988
+
989
+ **It cannot cost a frame.** The counts come from the same call that produces the output, with no
990
+ second pass, and recording is guarded: a bridge older than the telemetry has no `report` at all (the
991
+ member is optional — gate with `typeof ctx.report === "function"`), a report of nonsense records
992
+ zeros, and a transform that throws keeps the counts it reached. Telemetry never changes what is
993
+ drawn.
994
+
995
+ ### What a registration must satisfy
996
+
997
+ - `id`: the same rule as every other surface — a non-empty string, not `__proto__`/`constructor`/
998
+ `prototype`, no NUL byte.
999
+ - `transform`: a function of `(lines, ctx)`. Anything else throws **before** anything is stored.
1000
+ - Registering an existing `id` replaces that transform in place; `dispose()` removes exactly the one
1001
+ registration it created, and calling it twice is a no-op.
1002
+
1003
+ ### `/bridge-status`
1004
+
1005
+ The block is emitted only when a transform is registered, so a session that never used the pipeline
1006
+ keeps its report byte for byte:
1007
+
1008
+ ```text
1009
+ transforms: 2 registered
1010
+ - loon.bash-cards (order 10): applied, 13 lines, 4 candidates, 1 recognised, 1 rewritten, shapes: float 1
1011
+ - loon.strict (registration order): failed (definitions are missing), 13 lines, 0 candidates, 0 recognised, 0 rewritten
1012
+ ```
1013
+
1014
+ `applied` means the frame changed, `skipped` means the transform was called and left the rows as they
1015
+ were, `failed` carries its message, and `not run` means it was registered after the last frame. The
1016
+ outcome is the LAST frame's, not a history: a report is about what the user is looking at now. The
1017
+ counts after it belong to that same frame — lines received, then what the transform reported — and a
1018
+ `not run` transform has none, because no frame has asked it anything yet.
1019
+
1020
+ ## 14. Tool designs
1021
+
1022
+ A **design** decides how ONE tool's card is drawn, without owning the frame. It is declared per tool
1023
+ NAME and composed with the host's own card:
1024
+
1025
+ ```ts
1026
+ api.registerToolDesign({
1027
+ tool: "bash",
1028
+ id: "loon.bash-heading",
1029
+ label: "bash heading", // the value on the Cards line, not a row name
1030
+ name: "Loon kit", // optional INTEGRATIONS name (see §8)
1031
+ description: "heads the card with bash instead of the host's $ mark",
1032
+ preview: () => ({ title: "Cards · bash heading", sample: "╭─ bash npm test ─╮" }),
1033
+ apply(lines, ctx) { // the host's rows in, the rows to draw out
1034
+ return lines.map((line) => rewriteTheMarkOnly(line));
1035
+ },
1036
+ });
1037
+ ```
1038
+
1039
+ ### The convention: lines in, lines out
1040
+
1041
+ `apply(lines, ctx)` receives the rows the HOST's own card produced — ANSI sequences, padding, the
1042
+ right-aligned hint, the frame's rails, everything — and returns the rows to draw. `ctx` is
1043
+ `{ width, tool, theme, part }`: the width the host asked its own component to render at, the tool name
1044
+ the design was declared for, the host's opaque theme object, and which half of the card this pass is
1045
+ drawing (`"call"` for `renderCall`, `"result"` for `renderResult`).
1046
+
1047
+ **`part` is what tells the call from the result.** A card has two components and the host draws both
1048
+ with the same chrome, so the rows alone cannot distinguish a heading from output: under the `float`
1049
+ style a result body row that begins with `$ ` (a script echoing its commands) sits exactly where a
1050
+ heading sits. The bridge wraps the two renderers separately and sets `part` per renderer, so a design
1051
+ that must not touch the result returns it unchanged when `ctx.part === "result"` instead of guessing
1052
+ from drawn text. The field is additive: a design that never reads it behaves exactly as before.
1053
+
1054
+ **The frame is the host's, so the card style is preserved by construction.** With `Card style` on
1055
+ `neon` the card is still neon, with `float` it is still float, because the chrome was drawn before the
1056
+ design saw a byte of it. The bridge never re-frames, re-measures or clips a design's output (§13's
1057
+ width rule is the transform pipeline's); a design keeps the rows it was given exactly as wide as the
1058
+ host made them.
1059
+
1060
+ Two rules follow from that:
1061
+
1062
+ - **Change only what the design is for.** A bash design that heads the card `bash` changes the
1063
+ heading row and nothing else: every other row — every ANSI run, every pad, the hint, the rails —
1064
+ comes back byte-identical. The rows it does change stay the host's rows; they are edited, never
1065
+ rebuilt from a second grammar.
1066
+ - **It is declared by tool NAME, not recognised by drawn text.** The bus already knows which tool's
1067
+ card it is wrapping; a design that scans a frame for the word it expects is the fragility this
1068
+ surface replaces (the host retitled the bash card and every drawn-text grammar went blind).
1069
+
1070
+ ### Declared per tool name (S7)
1071
+
1072
+ A design names a tool (`bash`, `write`, `edit`, `mcp__engram__mem_save`, `lsp_navigation`, …). The bus
1073
+ keeps one registry per name, no name is special-cased, and a design declared for a tool that has no
1074
+ card yet applies as soon as Pi builds one: Pi resolves renderers when the card is CONSTRUCTED, and the
1075
+ bridge's one resolver is installed for the whole session.
1076
+
1077
+ ### When the bridge is not first in the chain (S10)
1078
+
1079
+ Pi composes tool-renderer resolvers in **extension load order** (`runner.js:550-553`) and exposes no
1080
+ API to reorder them (`loader.js:298-302` only pushes). A resolver loaded before this package decides
1081
+ first, and one that answers WITHOUT calling `next()` hides the bridge's resolver completely. The
1082
+ host's own quiet bash resolver does exactly that while `Card content` is `minimal`
1083
+ (`extensions/quiet-tools.ts:910-914`), which is why a selected design could still leave every card
1084
+ drawing `$ …`.
1085
+
1086
+ The design therefore has a second path: after Pi constructs a card, the bridge adopts it. The
1087
+ conversation component the bridge already wraps for card styles has Pi's `ToolExecutionComponent`s as
1088
+ children; each carries `toolName` and the `toolDefinition` Pi resolved for it. On every conversation
1089
+ render pass, before the host renders its children, the bridge replaces that card's `toolDefinition`
1090
+ with the same wrapper the resolver path uses (so the frame and the live-selection rule are
1091
+ identical), marks the wrapper so the two paths cannot stack, and invalidates the card so its stored
1092
+ renderer re-runs through the design. Cards Pi rebuilds (reload, `/tree`, session switch) are new
1093
+ children and are adopted on the next pass. A card of a tool with no designs is never touched; a card
1094
+ whose `toolDefinition` is missing or refuses the assignment is reported in `/bridge-status` and drawn
1095
+ by the host.
1096
+
1097
+ This does not replace the resolver path: when the bridge IS ahead in the chain its resolver wraps the
1098
+ renderers at construction, and adoption then recognises the marker and adds nothing. `/bridge-status`
1099
+ counts both per tool (`wrapper mounted on N cards`, `adopted N cards`), so the next failure says
1100
+ which path ran.
1101
+
1102
+ ### Selection, and the default
1103
+
1104
+ The selected design per tool is a row in `/gentle:customize` → `Cards`: one line per tool, named from
1105
+ the TOOL (never from the plugin or the id) and prefixed with the category's own word, whose values are
1106
+
1107
+ 1. `native` — **the host's own card, always first and the DEFAULT**, and
1108
+ 2. every design registered for that tool, in registration order, each shown by its `label`.
1109
+
1110
+ **The line name is `Card <tool>`**, the vocabulary the `Cards` category already uses for `Card style`
1111
+ and `Card content`: `Card bash`, `Card lsp navigation`, `Card mem save`. A row name is one to three
1112
+ plain words (`src/row-names.ts`), so the prefix leaves room for at most two tool words; a longer tool
1113
+ name keeps its TAIL (`mcp__engram__mem_save` → `Card mem save`, never `Card mcp engram`), a name that
1114
+ derives to no words reads `Card tool`, and two tools that read the same after that clamp are told
1115
+ apart with an ordinal (`Card mem save`, `Card mem 2`) that keeps the shape rule. The line's name is
1116
+ not persisted and is not identity: the store is keyed by the TOOL name (`bridge-tool-designs` →
1117
+ `selections` → `{ "bash": … }`) and a value is the design's `id`, so renaming the line moves nothing
1118
+ but the text the user reads.
1119
+
1120
+ The stored value is the design's `id`, so renaming a label never loses a user's choice. Changing the
1121
+ value repaints the cards already on screen: the wrapper reads the live selection on every render pass,
1122
+ so a design chosen after a card was drawn changes that same card. A selection naming a design that is
1123
+ no longer registered degrades to the host's own card and is reported as `missing` in
1124
+ `/bridge-status`.
1125
+
1126
+ ### What a switch costs (S13)
1127
+
1128
+ That repaint is **one differential frame**: `requestRepaint()` bumps the host's terminal-side cache
1129
+ revision (`invalidateSidebar(tui)`'s one step) and calls `tui.requestRender()` with no argument. It
1130
+ does **not** call `tui.invalidate()` and does **not** pass `force = true`. The wrapper renders the
1131
+ host's component and applies the live selection on top of whatever rows it answers with — including
1132
+ rows the host's own frame cache hands back (`ToolCardRows`, `cards-bash-v2`
1133
+ `extensions/quiet-tools.ts:612-624`, keys its frame on width, the host's style and whether the card
1134
+ is running; the design is not in that key), so no cache has to be dropped for a switch to land.
1135
+
1136
+ Measured on real pi-tui and Pi's `ToolExecutionComponent` on transcripts of 50, 200 and 600 children
1137
+ (half Markdown assistant messages with a code block and a list, half bash cards; median of 5 runs;
1138
+ Node 26; fake terminal that counts the bytes the TUI writes):
1139
+
1140
+ | 600 children, one switch | frame | bytes the TUI wrote |
1141
+ | --- | ---: | ---: |
1142
+ | `requestRender()` — what a switch does now | 21 ms | 0 (the diff finds nothing else) |
1143
+ | `tui.invalidate()` + a frame | 730 ms | 0 (every Markdown message re-parsed) |
1144
+ | `requestRender(true)` — forced | 33 ms | 6.9 MB (whole buffer cleared and rewritten) |
1145
+ | invalidate + force — before S13 | 753 ms | 6.9 MB |
1146
+
1147
+ The invalidate is the cost: it clears every mounted component's frame cache, so 300 Markdown
1148
+ messages re-parse and re-highlight before the frame can be drawn (`tui.js:585-590`). The force is the
1149
+ second: `resetRenderState()` throws away the previous frame, so the TUI clears the screen and
1150
+ scrollback and rewrites every row (`tui.js:658-663`). The host's own `Card style` switch never did
1151
+ either — it writes its value, bumps the sidebar revision and calls `requestRender()` with no
1152
+ argument (`extensions/gentle-shell.ts:3409-3412`) — which is why it never froze.
1153
+
1154
+ The other candidates were measured and NOT acted on, because the numbers refused them: reading the
1155
+ persisted selection costs 3.5 µs per card per frame (0.35 ms at 100 cards, ~1 ms at 300 — steady, and
1156
+ 0.1% of the old switch), and adopting a card after construction costs ~0.02 ms and one local
1157
+ invalidate per card, once. See `test/tool-design-cost.test.ts`, which pins the result by COUNTING
1158
+ calls instead of timing them: a switch invalidates no component, re-parses no Markdown row, requests
1159
+ exactly one differential frame, and performs at most one persisted-selection read per card per frame,
1160
+ whatever the number of Markdown rows in the transcript.
1161
+
1162
+ #### What a bridge-owned switch costs (S19)
1163
+
1164
+ `requestSurfaceRepaint()` — the path a bridge-owned row takes (a card style such as the example's
1165
+ `plain`, a section toggle) — used to keep the invalidation and the forced frame. It is now the same two
1166
+ steps as `requestRepaint()`: the host's sidebar revision and one differential frame. Measured with real
1167
+ pi components, with and without `tui.invalidate()` + `requestRender(true)`, the rows are byte-identical
1168
+ on the conversation, on a rail host panel and on a bridge section hidden, collapsed and expanded: the
1169
+ style is repainted by the wrapper on the host's own already-rendered rows on every pass, and a section
1170
+ toggle moves `sectionRailDigest`, which the host's rail memo already keys on.
1171
+
1172
+ Median of 7, 600 children, one switch: `plain` 697 ms before / 420 ms after, a host style 306 / 19 ms,
1173
+ a rail section toggle 99 / 3 ms. The control that keeps the zeros honest: a host card whose frame key
1174
+ omits the style is the only case an invalidation repairs, and it would deserve a LOCAL invalidate on that
1175
+ card, never the tree. `test/surface-repaint-cost.test.ts` bounds it by call counts (zero tree
1176
+ invalidations, zero prose re-parses, zero card invalidations, one differential frame, at 3 and at 200
1177
+ Markdown rows).
1178
+
1179
+ Stated residual: switching TO `plain` at 600 children still costs about 420 ms (the style draws 4 rows
1180
+ where the host's float card draws 7, so the transcript shrinks by ~270 rows and pi rewrites its buffer,
1181
+ and `transformCardLines` re-scans the transcript per pass); switching back costs about 16 ms. The rail
1182
+ model in that measurement reproduces the host's memo key (`lib/shell-sidebar-layout.ts:310-350`); it is
1183
+ a model, not the host's own module.
1184
+
1185
+ ### Registration discipline
1186
+
1187
+ Same rules as every other surface: `tool` and `id` are validated before anything is stored; a second
1188
+ registration with the same (`tool`, `id`) replaces in place and is REPORTED (never a silent takeover);
1189
+ two designs of one tool cannot share a visible label; `native` is reserved. A design that throws, or
1190
+ returns something that is not an array of strings, loses only its own card — the host's rows are drawn
1191
+ — and is reported once per (tool, id, message).
1192
+
1193
+ `name` is optional and does the same job as `RowProvider.name` (§2): a single-file extension with no
1194
+ owning `package.json` gets an INTEGRATIONS line only if it names itself, and a plugin that contributes
1195
+ ONLY designs is attributed like any other — its registrations are `accepted`/`rejected` in the
1196
+ report, and its `name` is sticky for the identity (§8).
1197
+
1198
+ The bridge registers exactly ONE `pi.registerToolRenderer` resolver for the whole process and fans it
1199
+ out to these designs. That is why two plugins contributing designs for the same tool do not stack
1200
+ wrappers, and why a plugin must never call `pi.registerToolRenderer` itself for a design.
1201
+
1202
+ ## Limits
1203
+
1204
+ - `registerRows`, `registerSection`, `registerCardStyle`, `registerTransform`,
1205
+ `registerToolDesign`, `requestRepaint`, `createStore`, `bridgeApi`, `isBridgeApi`, `onBridgeApi`
1206
+ and the `gentle-shell-bridge:ready` event are the contract; anything a plugin does inside `action()` beyond
1207
+ calling inline helpers is the plugin's own business.
1208
+ - **`Keymaps` is reserved.** A provider registered with `category: "Keymaps"` is refused before
1209
+ anything is stored, with an error naming the category and why (its own key capture and footer; not
1210
+ open to plugins yet); the refusal is a rejected registration in the plugin's INTEGRATIONS line and in
1211
+ `/bridge-status`. A single row whose own `category` is `Keymaps` is dropped and reported the same way,
1212
+ and its siblings still render. The comparison is trimmed and case-insensitive: ` keymaps ` and
1213
+ `KEYMAPS` are the same page; `Keymap` and `Key maps` are ordinary categories. `Profiles` and `Reset`
1214
+ are not reserved: a plugin row there renders as an ordinary line. A refusal of one row is listed under
1215
+ `row rejections` but is not counted in the plugin's INTEGRATIONS line (a build cannot reach it; only the
1216
+ registration refusal does).
1217
+ - **A new category becomes a page.** The host computes its page list once, in the view's constructor
1218
+ (`lib/visual-customize-view.ts:387`), so the rows must be in the array before that `map` runs; see
1219
+ [architecture.md](architecture.md) "A brand-new page". The cost is stated there. The one residual
1220
+ risk: a host that never derives categories from its menu while another array in the same factory is
1221
+ mapped exactly like the host's page derivation would have its rows injected into the wrong array and
1222
+ report `injected`; the real host derives them at `:387`, and with that shape a competing array stays
1223
+ `ambiguous` and nothing is injected.
1224
+ - **Sections join the rail after the native cards, never between them.** The host's status
1225
+ sections are unreachable (a closure), so "interleaved with `CHANGES`" is not offered; the
1226
+ limit is documented rather than faked.
1227
+ - **Resolver order is not ours.** Pi composes tool-renderer resolvers in extension load order and
1228
+ offers no reordering API, so a resolver ahead of this package that does not call `next()` hides the
1229
+ resolver path. Adoption after construction exists for exactly that case (S10); it depends on Pi's
1230
+ `ToolExecutionComponent` carrying `toolName` and a writable `toolDefinition`, and reports the card
1231
+ as an `(adoption)` failure instead of throwing when it does not.
1232
+ - The sidebar surface has been proven against a fake host that models the real rail shape
1233
+ (`test/fake-sidebar.ts`), not in a running TUI. In particular, whether the widget Pi hands
1234
+ the `tui` through renders nothing visible is **unconfirmed** (see
1235
+ [architecture.md](architecture.md) §12).
1236
+ - `current()` must read live state and `action()` must persist before returning. This is a
1237
+ correctness rule the bridge cannot enforce at runtime; a plugin that caches a stale value
1238
+ renders a lying `●`.
1239
+ - No priority or insertion-index API exists: order is subscription order only.
1240
+ - The runtime handshake is proven under `node --test` with a simulated process (a reset
1241
+ `globalThis`, a fake `pi.events` and a freshly loaded example per scenario), not by two live Pi
1242
+ extensions loading in either order inside one real session. What it pins: the announcement's name,
1243
+ payload and once-per-process rule, and that an early plugin registers every surface it contributes
1244
+ from it.
1245
+ - The announcement is only as good as Pi's bus: a host without `pi.events` gets no event, which is
1246
+ why the symbol remains the authority and the example reports that state honestly instead of
1247
+ waiting for something that cannot arrive.
1248
+ - A card style's frame is measured in terminal columns (wide characters count 2, combining marks
1249
+ 0); a style that mixes in its own images or escape tricks owns whatever the terminal does with it.
1250
+ - Card styles are proven against a fake conversation component that reproduces gentle-shell's
1251
+ `float` output, never in a running Pi TUI; the downstream-override conflict above is a property of
1252
+ a real session the fake cannot reproduce.
1253
+ - `registerCardStyle`, `registerTransform`, `registerToolDesign` and `requestRepaint` are optional
1254
+ on the published object: an object published by a copy older than that surface passes the gate but
1255
+ lacks it, so a plugin that needs one checks `typeof api.registerCardStyle === "function"` (or
1256
+ `registerTransform`, `registerToolDesign`, `requestRepaint`) first.
1257
+ - A design receives ROWS, a width and the PART it is drawing (`call` or `result`), never a renderer
1258
+ object and never the theme's shape. `part` is one of two literals; a design that ignores it is
1259
+ byte-identical to one written before the field existed, and a context produced by an older copy of
1260
+ the bridge carries no `part` at all — a design reads it as unknown (`ctx.part === "result"` is
1261
+ simply false) and keeps working.
1262
+ - A transform is a per-frame pass, so a slow transform is a slow frame; the bridge isolates its
1263
+ failures and clips its width, and can bound nothing else. It sees lines, never tool metadata.
1264
+ - The API is released (`0.3.0`). The `v1` symbol is the compatibility boundary: a breaking
1265
+ change takes a new key instead of mutating this one (§9). The `gentle-shell-bridge:ready` event is
1266
+ additive: a plugin that never subscribes loses nothing but the late-load case.