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,981 @@
1
+ # Architecture — how the bridge reaches gentle-shell's menu
2
+
3
+ Every claim in this document is either read from the cited source or observed in the real
4
+ TUI. Paths without a repository prefix are Pi (`packages/coding-agent/src/...`) or
5
+ gentle-shell (`extensions/...`, `lib/...`) files. Anything not proven is in **Limits** at
6
+ the end.
7
+
8
+ The contract itself lives in [contract.md](contract.md); the persistence decision in
9
+ [state.md](state.md).
10
+
11
+ ## 1. Shape
12
+
13
+ ```
14
+ plugin A ─┐
15
+ plugin B ─┼─ registerRows(provider) ─→ globalThis[Symbol.for(...)] ─→ bridge
16
+ plugin C ─┘ │
17
+ one ctx.ui.custom wrapper
18
+ │
19
+ capture during the factory call
20
+ │
21
+ fan-out: A + B + C rows, in order
22
+ │
23
+ gentle-shell VisualCustomizeView
24
+ ```
25
+
26
+ N plugins × M gentle-shell surfaces → N providers, one interception. The bridge is a typed
27
+ facade over a process-global bus, never a module-level singleton (see §5).
28
+
29
+ ## 2. One `ctx.ui` per runner, rebuilt on every bind
30
+
31
+ All extension contexts share a single UI object per runner, so a patch installed from one
32
+ extension is visible to every extension:
33
+
34
+ ```ts
35
+ get ui() {
36
+ runner.assertActive();
37
+ return runner.uiContext;
38
+ }
39
+ ```
40
+
41
+ `packages/coding-agent/src/core/extensions/runner.ts:896-898`.
42
+
43
+ That object is not stable. `setUIContext()` reassigns it
44
+ (`runner.ts:572-575`: `this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext`),
45
+ and `wrapUIPromptContext` returns a **new object literal** each time, with `custom` as an
46
+ own property wrapping `withUIPrompt` (`runner.ts:577-587`). Consequence: a patch does not
47
+ survive a rebind. The bridge therefore arms inside its `session_start` handler, and arming
48
+ must be idempotent — a non-enumerable marker installed with `Object.defineProperty` on the
49
+ target object, so a second `session_start`, a `/reload` or a double load never double-wraps.
50
+
51
+ ## 3. The `custom()` call chain
52
+
53
+ `custom` on the facade maps straight through to the interactive mode
54
+ (`packages/coding-agent/src/modes/interactive/interactive-mode.ts:2794`:
55
+ `custom: (factory, options) => this.showExtensionCustom(factory, options)`).
56
+
57
+ `showExtensionCustom` resolves the component with
58
+
59
+ ```ts
60
+ Promise.resolve(factory(this.ui, theme, this.keybindings, close))
61
+ ```
62
+
63
+ around `interactive-mode.ts:3136`, and forwards `overlay` / `overlayOptions`. Two facts
64
+ follow: factories may return a promise, and the factory receives the theme as its second
65
+ argument — the value the bridge can hand to providers as part of their row-building
66
+ context.
67
+
68
+ ## 4. Why duck-typing fails: the menu is sealed
69
+
70
+ gentle-shell builds its customize view inside the factory and returns something else:
71
+
72
+ - `extensions/gentle-shell.ts:3550` — `customizeView = new VisualCustomizeView({ rows, ... })`
73
+ - `extensions/gentle-shell.ts:3554-3561` — the factory returns a plain adapter:
74
+ `{ render, handleInput, handleMouse, focus, invalidate, dispose }`
75
+ - `extensions/gentle-shell.ts:3562` — the call itself passes
76
+ `{ overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } }`
77
+
78
+ The view lives only inside those closures. There is no `options.rows` on the returned
79
+ object, at any depth the bridge can see. This is precisely the bug that made a first
80
+ prototype a silent no-op: it inspected the component, found no rows, and did nothing.
81
+
82
+ ## 5. The one reachable moment
83
+
84
+ The view's constructor derives its categories from the rows array, synchronously, inside
85
+ the factory call:
86
+
87
+ ```ts
88
+ this.categories = [...new Set(options.rows.map(row => row.category ?? "Settings"))];
89
+ ```
90
+
91
+ `lib/visual-customize-view.ts:387`.
92
+
93
+ That `.map` runs before the factory returns and on the array the renderer will keep using.
94
+ So the bridge temporarily replaces `Array.prototype.map` for the duration of that single
95
+ call, records the row-like array **by reference** (not a copy), and restores the original
96
+ in a `finally` block. Restoring in `finally` is mandatory: an exception anywhere in the
97
+ factory must not leave the process with a patched `Array.prototype`.
98
+
99
+ Two properties make the captured reference live data rather than painted text:
100
+
101
+ - `categoryRows()` re-filters `this.options.rows` on every render
102
+ (`lib/visual-customize-view.ts:391-393`), so rows pushed after construction appear.
103
+ - `component.invalidate()` on the returned adapter delegates to `view.invalidate()`
104
+ (`extensions/gentle-shell.ts:3559`), which refreshes the frame.
105
+
106
+ Capturing an array reference is also why a marker on the captured array (non-enumerable,
107
+ via `Object.defineProperty`) matters: a second factory call for the same menu hands over
108
+ the same array, and rows must be injected once.
109
+
110
+ A mapped array counts as row-like when it holds at least three objects that each expose a
111
+ callable `action`. The threshold comes from the probe's verified run against the real menu;
112
+ the cost is explicit: a menu with fewer than three host rows would not be captured, and the
113
+ `/bridge-status` command reports `uncaptured (no-capture)` rather than passing silently.
114
+
115
+ ### Capture-selection rule
116
+
117
+ Each wrapped `custom` call owns one capture *session* with its own candidate list, and
118
+ the probe records a mapped array into the innermost active session only. That fixes
119
+ the target without guessing:
120
+
121
+ - A nested `custom` call — from the host factory or from a provider's own `rows()` —
122
+ gets its own session, so it can never steal the outer menu's capture, and the nested
123
+ menu receives its own injection.
124
+ - A nested session whose factory throws never resolves, so nothing is injected into
125
+ it; the outer session continues and resolves its own array.
126
+ - When one session captures **more than one** row-like array (an incidental
127
+ row-shaped array before the menu, for example), the bridge does not guess: the
128
+ outcome is `uncaptured (ambiguous)` and nothing is injected.
129
+
130
+ Arming is idempotent per target *and per function*: the non-enumerable marker holds the
131
+ exact wrapper installed and is read as an own property, so a rebind that gives an
132
+ instance a new `custom` always rearms even when it inherits an already-wrapped
133
+ prototype.
134
+
135
+ Two boundaries around that rule:
136
+
137
+ - The probe is installed around the **factory invocation**, not around the host call, so a
138
+ host that resolves its factory in a later microtask (`Promise.resolve().then(factory)`)
139
+ is still captured. A factory that itself returns a promise remains the documented
140
+ unsupported case, `uncaptured (async-factory)`.
141
+ - Every wrapper carries the generation it was armed in. `disableCaptureBackend()` bumps the
142
+ generation, so a wrapper that foreign code kept a reference to becomes a pass-through
143
+ instead of injecting into a menu after the backend was switched off.
144
+
145
+ ### A brand-new page, and why the early step is provisional (S18, S20)
146
+
147
+ `categoryRows()` filters `options.rows` live (`lib/visual-customize-view.ts:391-393`), but the page
148
+ *list* is derived once, in the constructor (`this.categories = [...new Set(options.rows.map(row =>
149
+ row.category ?? "Settings"))]`, `:387`). A row that joins an existing page therefore appears after a
150
+ late push; a row naming a new page never could, because nothing can select a page the list does not
151
+ hold (E8). The capture backend therefore has two moments: it pushes the providers' rows when the probe
152
+ observes that `map`, provisionally, and the end of the factory decides.
153
+
154
+ - **The proof.** Nothing is written to an array that is not proven at *that* `map` call, and the proof
155
+ runs on **every** call (the host may map its rows to labels first). The callback must derive
156
+ `row.category ?? "Settings"` for the array's own rows, `"Settings"` for a synthetic row with no
157
+ category and a sentinel page for a synthetic row naming one, and must not throw. The same callback is
158
+ then run over the rows about to be appended, **before** they are pushed, so the host's own `map` can
159
+ never throw because of a bridge row.
160
+ - **The reconciliation.** If the late decision picks the same array, the rows stay and the build reports
161
+ `injected`. If it picks another array, the session proved several (ambiguous), or the factory returned
162
+ a promise, the rows, the `Card style` splice and the marker are removed from the early array and the
163
+ previous behaviour applies exactly. When exactly one captured array proves itself the menu's rows it is
164
+ the target by evidence, so a merely row-like array mapped beside it no longer makes the build
165
+ ambiguous; two proven arrays, or two unproven ones, still do.
166
+ - **The rollback.** A factory that throws leaves no trace. The session stack and the patched
167
+ `Array.prototype.map` are restored first and unconditionally, and every rollback step is best effort
168
+ and never throws (E10: a host that froze its rows and then threw used to make the rollback throw,
169
+ replace the host's error and leave `map` patched). A session only ever rolls back its own array, so a
170
+ nested factory's rollback cannot touch the outer menu's rows.
171
+
172
+ Net guarantee: after any factory invocation, no array holds bridge rows except the one the late decision
173
+ chose. Costs, stated: for the duration of the factory an array that satisfies the proof can hold bridge
174
+ rows, visible only to code running inside that factory; the host's category mapper runs once more per
175
+ row-like `map` call and once per appended row; when a guess is rolled back the providers are built twice
176
+ for that menu build; and the proof is pinned to the host's default `Settings`, so a host that renames it
177
+ degrades to the late path (no new page) rather than guessing. Not reviewed by anyone: rollback when the
178
+ host reorders or removes our rows or pushes its own after them (Proxy arrays included), and a stale
179
+ `cardStyleRowMode()` after a rolled-back guess.
180
+
181
+ ## 6. Why one bridge beats N wrappers
182
+
183
+ Two independent map-swapping wrappers chain badly. Whichever installs its patch last sees
184
+ the factory's `.map` call first and captures the array; the other captures nothing and
185
+ degrades to a warning. The result is that only one plugin's rows apply, non-deterministically
186
+ with load order, and the losing plugin looks broken for reasons it cannot debug.
187
+
188
+ The bridge installs **one** patch, guarded by a nesting counter (the factory may itself
189
+ trigger further UI work), and fans the single capture out to every registered provider:
190
+
191
+ 1. snapshot the provider list in subscription order,
192
+ 2. build each provider's rows independently, inside its own `try`,
193
+ 3. drop a provider's rows on throw, record the failure for `/bridge-status`, continue,
194
+ 4. push the surviving rows into the captured array and invalidate once.
195
+
196
+ Failure isolation is per provider, so a broken plugin costs its own rows and nothing else:
197
+ not the menu, not the other providers.
198
+
199
+ ## 7. Process-global state cannot live in a module
200
+
201
+ Pi loads each extension through its own jiti loader with `moduleCache: false`; gentle-shell
202
+ documents the consequence directly — every extension gets a copy of any imported module
203
+ (`lib/shell-card.ts:30-32`). Shared state must therefore sit on `globalThis` behind a
204
+ `Symbol.for` key, exactly as gentle-shell does for its card style
205
+ (`lib/shell-card.ts:40-41`): `Symbol.for("gentle-pi.card-style")` indexing an augmented
206
+ `globalThis`.
207
+
208
+ The npm package is a typed facade over that process-global bus. Registration, diagnostics
209
+ and the capture marker all resolve through the same `Symbol.for` slot, so three providers
210
+ loaded from three copies of the package see one bus.
211
+
212
+ The bridge's own key is `Symbol.for("gentle-shell-bridge.providers")` for the registration
213
+ list, with sibling slots for backend state and markers (`…capture.state.v1`,
214
+ `…capture.wrapped.v1`, `…capture.injected.v1`, `…hook.state.v1`, `…hook.armed.v1`,
215
+ `…store.state.v1`). The host key `Symbol.for("gentle-pi.customize-row-providers")` is only
216
+ ever **read**: the bridge pushes one fan-out provider into it when a host exposes one, and
217
+ otherwise leaves the slot absent.
218
+
219
+ ## 8. Row grouping semantics the bridge relies on (it does not re-implement them)
220
+
221
+ - `lineEntries()` coalesces **consecutive** rows that share `choice.setting`
222
+ (`lib/visual-customize-view.ts:395-401`): same setting, adjacent index, one line. Same
223
+ setting with another row between it becomes two lines with independent bullets.
224
+ - `renderSettingLine()` prints `entry.setting` as the line's name and marks each value with
225
+ `●` for current and `○` for the others (`lib/visual-customize-view.ts:583-595`).
226
+ - `choice.hint` feeds the preview headline (`lib/visual-customize-view.ts:745`).
227
+ - `applyOnMove !== false` makes ←/→ apply a choice immediately rather than Enter
228
+ (`lib/visual-customize-view.ts:565`). The bridge therefore sets `applyOnMove: false` on EVERY row it
229
+ contributes — its own `Sections`/card-style rows in `./sections-rows.ts` and `./card-style-rows.ts`,
230
+ and any provider row, normalised on a copy in `./rows.ts` (`confirmOnEnter`) — so a move only moves
231
+ the `›` cursor and Enter applies. The host's own rows keep their own field, so a line that holds both
232
+ (the extended `Card style` line) is per-row correct; the focused line's footer then reads
233
+ `←/→ choose · Enter apply` (`lib/visual-customize-view.ts:35-38`).
234
+ - After any action the view requests its own re-render
235
+ (`lib/visual-customize-view.ts:579`), so a plugin that persists inside `action()` sees an
236
+ updated `●` without asking for a repaint.
237
+ - `category` is TYPED as a closed union of existing categories
238
+ (`lib/visual-customize-view.ts:105`) but at runtime any string is a page, because the view derives
239
+ its page list from the rows (`:387`); full row shape at `:107-128`, inline helpers at `:129-140`.
240
+
241
+ The bridge's own rows obey the same rule. Its `Sections` and card-style rows put a human label in
242
+ `choice.setting` (`music`, `plain`) and in the row's `label` — never a registration id, which would
243
+ read as the old `Section: bash-card.status` (`src/row-labels.ts`). Identity stays in the registry, so
244
+ replacement, disposal and the stored visibility key remain keyed by id while nothing id-shaped is
245
+ rendered. Two sections whose labels would read the same are disambiguated in render order
246
+ (`music`, `music 2`, …) and `/bridge-status` reports the assignment in force.
247
+
248
+ The bridge ships no renderer. Every visual behaviour above belongs to gentle-shell, which
249
+ is why the contract can promise row kinds without promising pixels.
250
+
251
+ ## 9. Reference implementation
252
+
253
+ `/home/jona/.pi/agent/extensions/loon-customize-probe.ts` (76 lines) is a throwaway probe,
254
+ not production code, and it implements exactly this capture trick. Confirmed in the real
255
+ TUI: the `Cards` category rendered `Cards · 1/3` with the injected line
256
+ `Card bash ● activity ○ output`.
257
+
258
+ The probe is behavioural evidence for the backend, and it is also the honest baseline for
259
+ its weaknesses: probe-local state resets on `/reload` (no persistence), it recognises the
260
+ row array by shape, and it warns through `ctx.ui.notify` rather than through a diagnostics
261
+ surface. The package replaces all three.
262
+
263
+ ## 10. Detection and reporting
264
+
265
+ Every branch that can end with "no rows appeared" is a reportable outcome on the `/bridge-status`
266
+ command: armed but never captured, captured a row array that failed the shape check,
267
+ provider threw, provider returned nothing, duplicate `category` + `setting` across
268
+ providers. Silent success is not a valid state.
269
+
270
+ ### The plugin's own INTEGRATIONS line
271
+
272
+ The host renders Pi's extension-status map (`footerData.getExtensionStatuses()`) as the INTEGRATIONS
273
+ card: one row per entry, plus the permissions row (`extensions/gentle-shell.ts:357-385`,
274
+ `lib/shell-bar.ts:289-298`). A row is the WHOLE opaque string the extension set via
275
+ `ctx.ui.setStatus(key, text)`; the host strips its ANSI and paints every entry with one shared role,
276
+ so a glyph must live inside the text and colour belongs to the host (`lib/shell-bar.ts:151-155`).
277
+ `undefined` removes a key, which is what `session_shutdown` uses.
278
+
279
+ Because the host has no notion of "a plugin", the bridge owns exactly one key per plugin
280
+ (`src/plugin-lines.ts`). Attribution needs no configuration from the author: every registration in
281
+ `src/bus.ts` goes through `attributeRegistration`, which reads the calling module from the stack
282
+ once per registration (the first frame outside this package's `src/`), walks up from it to the
283
+ nearest `package.json` with a non-empty `name`, and caches BOTH the answer and the plugin identity it
284
+ implies per module. The identity is the package root — so every module in that package is the same
285
+ plugin — or the registering module when no package owns it. Four outcomes: another package (a
286
+ plugin, whose display name is derived from the package name), this package (plumbing — the bridge's
287
+ own providers and fallbacks never claim a row), no owning package (a single copied file: fail
288
+ closed, no row, reason in `/bridge-status`), and the Pi distribution (below). An optional `name` on
289
+ any registration is the documented way for an unattributable file to name itself; it is validated
290
+ against the row-name shape, never rewritten.
291
+
292
+ The Pi distribution is never a plugin identity (I11): a module attributed to the package that owns
293
+ the running `pi` entry point (`process.argv[1]`, realpath resolved) or to a package whose manifest
294
+ declares the `pi` executable (`bin.pi`) fails closed exactly like an unattributable module. A shared
295
+ extensions directory inside the Pi installation therefore cannot produce a second `Pi coding agent`
296
+ row; a plugin that must be named from such a location names itself explicitly. Recognition is by
297
+ declaration, not by matching a package name.
298
+
299
+ One key per identity is hashed from the package root (or the module path when it has no owner) and
300
+ NEVER from the display name, so a plugin's ten registrations update one row in place instead of
301
+ adding ten, and a rename of the package or of the explicit name repaints the same key without
302
+ leaving an orphan (I10). An explicit name is sticky for the identity (I9): the first registration
303
+ that declares a usable one fixes it, later registrations inherit it whether or not they repeat it,
304
+ and a later DIFFERENT name never creates a row and never renames — the first name is kept and the
305
+ conflict is reported in `/bridge-status`. A name is attribution, not mutable state, so a deliberate
306
+ rename ships as an edit plus `/reload`, a new registration lifetime. The text is
307
+ `<name> · <ready|degraded>`: `ready` is derived — one rejected registration (an invalid id, a row
308
+ label that broke the shape rule) repaints the same key as `degraded`. `installPluginStatus` runs on
309
+ `session_start` (for plugins that registered before any `ctx.ui` existed) and paints or repaints;
310
+ `clearPluginStatus` runs on `session_shutdown` and clears every key this module set with `undefined`.
311
+ A `ctx.ui` without `setStatus` makes the feature a reported no-op: nothing here can throw into a
312
+ plugin's registration path.
313
+
314
+ What is proven where: the bridge's side is tested against a fake footer (an object with
315
+ `setStatus`), plus real temporary plugin packages for attribution. The host's rendering — sort
316
+ order, shared role, ANSI stripping — is read-only evidence from gentle-shell, not executed here.
317
+
318
+ ## 11. Backend selection and module boundaries
319
+
320
+ Arming happens once per `ctx.ui` object, from `session_start` in `src/extension.ts`. The
321
+ order is fixed: if `globalThis[Symbol.for("gentle-pi.customize-row-providers")]` exists and
322
+ is usable — a plain array, or an object with a callable `register` — the bridge pushes one
323
+ fan-out provider and patches nothing at all. A present-but-unusable registry — or one that
324
+ does not accept the provider, because `push` did nothing, `register` returned `false`, or
325
+ either threw — is reported as `fallback-capture` and the capture backend is used instead,
326
+ never silently ignored. Dedupe does not mutate the host registry: identity in a
327
+ `WeakSet` in the process-global state is authoritative, so a frozen registry still gets
328
+ exactly one provider across arms and package copies. When the host removes the slot,
329
+ diagnostics stop reporting `hook`.
330
+
331
+ | Module | Owns |
332
+ | --- | --- |
333
+ | `src/index.ts` | the plugin-facing API: `registerRows`, `registerSection`, `registerCardStyle`, `registerTransform`, `createStore`, `STORE_SCHEMA_VERSION`, `VERSION`, `bridgeApi`, `isBridgeApi`, types; importing it also publishes the handshake through `./api.ts` |
334
+ | `src/api.ts` | the runtime handshake: the one `Symbol.for` slot, the frozen published object, the shape gate, `bridgeApi()` and publication |
335
+ | `src/extension.ts` | the Pi entry from `pi.extensions`: `session_start` arming (rows + sidebar widget), the `Sections` provider, and the `/bridge-status` command |
336
+ | `src/arm.ts` | backend selection and the arm result |
337
+ | `src/capture.ts` | the single `Array.prototype.map` patch and the captured array |
338
+ | `src/hook.ts` | the host-registry probe and the one fan-out provider |
339
+ | `src/bus.ts` | the `globalThis` provider list and its ordered snapshot, plus the section, card-style and transform registries |
340
+ | `src/rows.ts` | the one row builder both backends call, with per-provider isolation and the row-name shape check |
341
+ | `src/row-names.ts` | the enforced row-name shape (one to three plain words, no structural marks) and the rejection records `/bridge-status` lists |
342
+ | `src/sidebar.ts` | the rail interception: the state symbol, the patched `parts` map, the rail wrapper |
343
+ | `src/sidebar-render.ts` | the section renderer: card rows and groups per frame, clipping, failures |
344
+ | `src/sections-rows.ts` | the bridge's own `Sections` toggle rows |
345
+ | `src/row-labels.ts` | the visible label for those rows: `rowLabel` or a derived one (clamped to the shape), plus the deterministic collision assignment (never an id) |
346
+ | `src/visibility.ts` | bridge-owned section visibility in the namespaced store |
347
+ | `src/store.ts` | `createStore`, the namespaced path and the atomic write |
348
+ | `src/status.ts` | the `/bridge-status` text |
349
+ | `src/transform-pipeline.ts` | the ordered per-frame transform pass: width clipping, throw isolation and the last frame's outcomes |
350
+ | `src/config.ts`, `src/types.ts` | config-home resolution; the row, provider and context types |
351
+
352
+ Because both backends call `src/rows.ts`, a plugin sees the same `registerRows` semantics on
353
+ either one. Because the arming markers live on the target objects and the state slots live on
354
+ `globalThis`, a second copy of the package arms nothing new and reads the same diagnostics.
355
+
356
+ ## 12. The sidebar surface
357
+
358
+ The second surface a plugin can publish to. Same shape as the rows: one process-global registry,
359
+ one interception, one place that reports why nothing appeared.
360
+
361
+ ### Where the rail lives, verified
362
+
363
+ - Rail state hangs on the **terminal** behind
364
+ `Symbol.for("gentle-pi.experimental-sidebar.state")`, not in module scope
365
+ (`lib/shell-sidebar.ts:6,78-80`), so separately loaded extensions share one rail.
366
+ - `sidebarPart` writes the part into `state.parts` (`:88`) and deletes it on dispose (`:92-95`).
367
+ - The layout paints a hard-coded key order — `["todo","jobs","agents","footer"]`
368
+ (`lib/shell-sidebar-layout.ts:344`) — so an unknown key never renders: appending to an
369
+ existing part is the only way in, and the `footer` part is painted last.
370
+ - A part's cards come from `cardGroups` (`:392-410`) fed into `sectionCardRows` (`:35-44`), which
371
+ is why a wrapped rail can look native: the host draws the frame, we supply rows and ranges.
372
+ - The status sections (`CHANGES`, `STATS`, `INTEGRATIONS`) are built inline in a closure
373
+ (`lib/shell-bar.ts:247,251-316`): unreachable, so no interleaving is possible.
374
+ - The host's own `footer` part is registered at `extensions/gentle-shell.ts:2681`; readers reach
375
+ another part's `spend` through `parts.get("agents").spend()` (`:2652`), which is why every field
376
+ of the wrapped part is forwarded untouched. `digest` is the one field the wrapper owns, because the
377
+ layout memo re-renders a part only when its VALUE moves (`:114-119`): the host's own answer is
378
+ forwarded unchanged while nothing contributes rows, and the contributed rows' signature is
379
+ appended when they do (N14).
380
+
381
+ ### The interception
382
+
383
+ `src/sidebar.ts` patches the **map instance's own** `set` and `get` instead of replacing the map or
384
+ subclassing it: every holder keeps the map it already has, so a part registered after our arm is
385
+ still captured, and a part registered before it is wrapped on install. Two markers keep it
386
+ idempotent across arms, rebinds and package copies: the map's own property holds the exact `set`
387
+ function installed, and the wrapper rail holds the exact rail it wraps. A foreign wrapper already
388
+ on the part is wrapped around, never replaced; `disableSidebarBackend()` restores the host's part
389
+ and leaves any retained wrapper as a pass-through (a generation guard).
390
+
391
+ ### The renderer
392
+
393
+ `src/sidebar-render.ts` contributes the section rows and their card groups per frame. A hidden
394
+ section is not built at all, and neither is a COLLAPSED one: folding is a user choice, and a folded
395
+ body costs nothing (its `lines()` is not called). A throwing `lines()`/`meta()`/`onClick`, a sparse
396
+ array, or a row that is not a string and has no usable `label` loses only that section's body and is
397
+ recorded for `/bridge-status`.
398
+
399
+ A plain string row is clipped to the width the host passes and the body is capped at 8 rows with a
400
+ visible `… +N more` marker. Width is measured the way the HOST measures a row — `visibleWidth`,
401
+ where a TAB is three columns and a grapheme cluster is charged as a whole — because the host's gate
402
+ and card frame read the row with that model (`src/card-style-transform.ts`'s `hostColumns`);
403
+ measuring in UTF-16 units let a CJK row or a tabbed row measure wider than the budget and the host
404
+ dropped the whole rail (`lib/shell-sidebar-layout.ts:410`). A structured row is fitted by the panel
405
+ builder in the same columns, and padded to the width it was handed, exactly as the host's own
406
+ `renderPanelRow` pads (`lib/shell-panel.ts:78-80`).
407
+
408
+ ### The header and the panel builders (N11, N15)
409
+
410
+ The host draws its own rail sections with `renderPanelHeader` / `renderPanelRow` and paints a hover
411
+ with `paintHoverable` (`lib/shell-panel.ts:44-80`, `lib/shell-hover.ts:33-38`): `▾`/`▸` by `open`, the
412
+ uppercased title in `text` (bold, `warning` while hovered), the right-aligned count in `muted`, rows
413
+ indented by two and fitted to the width. `src/section-panel.ts` reproduces that drawing exactly, and
414
+ it gets the theme from the same place the host does: the second argument of Pi's `setWidget` factory.
415
+
416
+ The host's two builders are NOT importable — gentle-shell is not a dependency, and Pi loads every
417
+ extension through its own jiti loader with `moduleCache: false`, so `lib/shell-panel.ts` is a
418
+ separate module graph with no published seam. Rather than claim a reach it does not have, the bridge
419
+ LOOKS for them on every object it is handed — the rail part, the Pi theme, the terminal's sidebar
420
+ state — uses them when they are found, and otherwise draws with the port. `getPanelReport()` feeds
421
+ `/bridge-status` with what was looked for, what was found, and whether a theme had arrived; when the
422
+ answer is `fallback` the report also carries the gap, so "the card does not look native" always has
423
+ an answer. `test/section-panel.test.ts` transcribes the host's own functions and compares bytes, which
424
+ is what makes "byte-identical fallback" a claim a test enforces instead of a comment.
425
+
426
+ ### Width: the one thing a contributed row can cost (N17–N20)
427
+
428
+ The rail column is allocated from the layout, not from a part's output: `basis: RAIL_WIDTH, grow: 0,
429
+ shrink: 0` (`lib/shell-sidebar-layout.ts:493-496`), so pi-tui never measures the child
430
+ (`dist/layout.js:124`) and the conversation keeps `columns - 53`. The one width decision the host DOES
431
+ take from a part's rows is whether to paint the rail at all: every rail row must fit `contentWidth`
432
+ (`:410`), and one wider row drops the entire rail and hands the conversation every column. So a
433
+ contributed row that measures wider than the width it was given changes the layout's widths.
434
+
435
+ That is why the bridge measures with the host's own model. `hostColumns`
436
+ (`src/card-style-transform.ts`) ports `visibleWidth` (`pi-tui/dist/utils.js:212-268`): ANSI is zero
437
+ columns, a TAB is three (`graphemeWidth`, `:148-155`), and the rest is segmented into grapheme
438
+ clusters charged as a whole (`:255-258`). `src/section-panel.ts` fits, pads and clips with it, and
439
+ `src/sidebar-render.ts` clips an author's plain row with it, so a row fitted to `N` is `N` columns to
440
+ the host — the padded rows the host's own parts return (`renderPanelRow` → `fitPanelLine`,
441
+ `lib/shell-panel.ts:31-40,78-80`) and the bridge's are the same rule, matched rather than chosen.
442
+ `test/rail-width.test.ts` models the rail path and pins both halves; `getSectionFrameWidth()` feeds
443
+ `/bridge-status` with `section width: <N> columns given, widest row <M>`, so the next width bug is a
444
+ number in the report instead of a screenshot.
445
+
446
+ ### Colouring and the theme
447
+
448
+ Pi hands a widget factory `(tui, theme)`; gentle-shell's own parts take their theme the same way, and
449
+ `src/extension.ts` now keeps it (`setSectionTheme`). Before that factory runs there is no theme: the
450
+ rows are drawn uncoloured rather than guessed at, and the report says `theme missing`.
451
+
452
+ ### The pointer (N12)
453
+
454
+ Clicking is POSITIONAL in this host, not a row field. The layout maps a pointer event into the part's
455
+ frame and calls the part's `handleMouse` with those coordinates (`lib/shell-sidebar-layout.ts:245-259`),
456
+ and the wrapper already receives that call, so the bridge's own rows can answer it. The backend hands
457
+ the contributor `position.startRow` — how many rows the host painted before the contribution, recorded
458
+ by the render that preceded the event — and `src/sidebar-render.ts` maps `y - startRow` onto the rows
459
+ it built. A row with `onClick` is clickable and highlights under the pointer; a row without it is
460
+ inert and declines (so the host's own dispatch continues). The header is always actionable: it folds
461
+ the section.
462
+
463
+ The hover claim uses the host's own published slot rather than a private channel: `SidebarState.hover`
464
+ is declared as "shared across separately loaded extensions" (`lib/shell-sidebar.ts:31`), and
465
+ `src/section-pointer.ts` stores `{ owner, clear }` there with the host's own semantics — the previous
466
+ owner's `clear()` runs when the owner changes, and the host's "pointer left the rail" call
467
+ (`setSidebarHover(tui)` with no owner) clears ours. `NativePointerRegion` itself is not reachable and
468
+ is not needed: the dispatch it adapts is the one the bridge already receives.
469
+
470
+ **Who sees a move first (S21).** The host's own region answers EVERY move inside its frame with
471
+ `{ handled: true }` — it looks the row up, paints the target and, with no target, clears and still
472
+ reports handled (`lib/native-pointer-region.ts:80-88`, `extensions/gentle-shell.ts:2732-2741`). A click
473
+ is the different path: with no target it returns `undefined` (`:2747-2757`), which is why folding a
474
+ section worked while NOTHING in a contributed section ever highlighted. The wrapper used to offer the
475
+ event to the host first and only ask the contributor when the host declined, so the host swallowed
476
+ every move. It now offers a MOVE whose `y` falls inside the contribution's own row range —
477
+ `[startRow, endRow)`, both recorded by the render that preceded the event — to the contributor first,
478
+ and falls through to the host when the contributor declines, so the host still clears its own hover on
479
+ our inert rows. Every other event keeps the host first. This cannot steal a host card's pointer: a host
480
+ target only ever lives inside the host's own lines, which end where `startRow` begins. Pinned by
481
+ `test/sidebar-hover.test.ts`, including "the host's own row keeps the pointer" and "one click, one
482
+ call" (`N12`). Under tmux, zellij or screen the host forwards no plain move events at all
483
+ (`lib/shell-hover.ts`'s platform note), so hover cannot be checked there.
484
+
485
+ ### Repaint and the digest (N13, N14)
486
+
487
+ A collapse persists to the bridge store and then asks for the frame synchronously, through the same
488
+ two steps the host uses for a part-owner write (`invalidateSidebar` + `requestRender`, `src/repaint.ts`).
489
+ A LIVE field needs nothing but the digest: the memo re-renders a part when its digest changes, so the
490
+ contributed rows' signature (bodies, `meta`, collapse flags, the hovered row, and what the theme
491
+ paints) is folded into the wrapper's `digest` and a changed `meta` reaches the screen on the next
492
+ layout pass. Reading `meta()`/`lines()` from the digest is the same trade the host's own parts make
493
+ (`extensions/gentle-agents.ts:2255-2260`).
494
+
495
+ ### The `Sections` toggle names
496
+
497
+ `src/sections-rows.ts` builds the toggle from the live registry on every menu build, and
498
+ `src/row-labels.ts` decides what it reads: the author's `rowLabel`, else the title made readable
499
+ (lowercase words, dots and punctuation dropped, clamped to three words). The label is BOTH the row's
500
+ `label` and the line's `choice.setting`, because the host prints the setting as the line's name
501
+ (`lib/visual-customize-view.ts:583-595`); the section id never reaches either. Equal labels are
502
+ resolved in render order with the lowest free ordinal suffix, and `collectStatus()` uses the SAME
503
+ function, so the menu and `/bridge-status` cannot disagree about which section reads `music 2`.
504
+ Status output keeps the ids on purpose: it is read by the plugin author diagnosing a registration,
505
+ not by the user picking a toggle.
506
+
507
+ The LABEL TEXT has one shape (`src/row-names.ts`), because a row named `Bash card → Bash rendering`
508
+ was the third defect of its class: one to three plain words, no arrow, colon, dot, pipe, slash or
509
+ bracket. It is enforced at both boundaries that can create a row name, and never by rewriting the
510
+ author's text: a section or card style whose `rowLabel` breaks the shape is not stored at all, and a
511
+ provider row whose line name or label breaks it is dropped as `buildRows` stages it — absent from the
512
+ host's array and from the build record `collectStatus()` reads, so it is not part of the effective
513
+ set. The rejected string is recorded verbatim with the rule it broke and `/bridge-status` prints both
514
+ under `row rejections`. Derivation from `title`/`label` applies the same shape (marks become
515
+ word breaks with the rest of the punctuation; the result is clamped), so a derived name can never be
516
+ rejected.
517
+
518
+ ### Acquiring a `tui` — and what is unconfirmed
519
+
520
+ A rail state is reached through a `tui` (`sidebarState(tui)` reads `tui.terminal`). Pi exposes
521
+ **no `tui` getter**: `ExtensionUIContext` in `dist/core/extensions/types.d.ts` mentions `TUI` only
522
+ as a factory parameter. The only surfaces that hand an extension's factory the real TUI are
523
+ `ctx.ui.setWidget(key, (tui, theme) => Component)` — `interactive-mode.js:1998`,
524
+ `content(this.ui, theme)` — plus `setFooter` (`:2084`), `setHeader`, `custom` and
525
+ `setEditorComponent`, and each installs a rendered component. gentle-shell itself gets its `tui`
526
+ this way (`extensions/gentle-agents.ts:2165-2175`).
527
+
528
+ So `src/extension.ts` installs a widget factory that keeps the `tui` it receives and returns a
529
+ component whose `render()` is `[]`. A missing `setWidget`, or a factory the host has not run yet, is
530
+ recorded as `no-widget-surface` and reported as `sidebar: not armed (reason)`.
531
+
532
+ **UNCONFIRMED:** whether a widget component that renders zero rows leaves the editor area visually
533
+ untouched has not been observed in a running Pi TUI. Nothing in the docs says either way; the
534
+ empty component is the minimal guess, and it is listed here rather than claimed.
535
+
536
+ ## 13. Runtime discovery: a frozen object, not a path
537
+
538
+ How a plugin that does not depend on this package reaches it. Everything above assumes the plugin
539
+ already holds `registerRows` / `registerSection`; this section is how it gets them with nothing
540
+ installed, configured or imported.
541
+
542
+ ### What Pi does and does not offer
543
+
544
+ - **No service registry.** Pi's extension API (`docs/extensions.md`) exposes registration calls
545
+ (commands, tools, providers, MCP servers, handlers) but no lookup that returns another
546
+ extension's live object. There is nothing a plugin could call to ask "is gentle-shell-bridge
547
+ loaded?".
548
+ - **`pi.events` is the only sanctioned cross-extension channel** (`docs/extensions.md:1965-1972`,
549
+ "Shared event bus for communication between extensions"), and it is a notification bus:
550
+ `emit`/`on` delivers values to listeners at emit time. It is not a registry the host reads
551
+ synchronously while it builds the menu. gentle-shell builds its rows inside the `ctx.ui.custom`
552
+ factory (§5) and reads the rail map at render time (§12), so a plugin reachable only through an
553
+ event handler cannot put rows into either. Events are not the seam for *delivery*. They are the
554
+ only way to learn that a package loaded later published something, which is what the announcement
555
+ below uses — and why it carries a signal ("look again") instead of the API itself.
556
+ - **`globalThis` is the one object two separately loaded extensions already share** in one
557
+ process, and gentle-shell already relies on it: one stable `Symbol.for` key per piece of
558
+ process-global state (`lib/shell-card.ts:40-41`, `lib/card-content-policy.ts:78`,
559
+ `lib/shell-sidebar.ts:6`), because Pi loads every extension through its own jiti module
560
+ instance (§7).
561
+
562
+ ### The convention this follows
563
+
564
+ - `pi-lens` mounts its IO bridge at a stable `Symbol.for` key with a version constant, first-wins
565
+ and frozen, and a breaking major takes a **new** symbol key instead of mutating the old slot
566
+ (`clients/io-bridge-contract.ts:25-32`, `clients/process-bridge.ts:48-81`).
567
+ - gentle-shell names its shared payloads with a `schema` string and gates on the exact shape
568
+ before using them (`lib/card-content-policy.ts:11,40`, `lib/card-style-policy.ts:29`).
569
+
570
+ The bridge combines the two. `Symbol.for("gentle-shell-bridge/api/v1")` holds a frozen object
571
+ carrying the schema string, the major version and the three callable members, and the consumer
572
+ checks all of them with `isBridgeApi` (`src/api.ts`). `v1` in the key is the major: `v2` is a new
573
+ key and `v1` is never mutated.
574
+
575
+ ### First wins, and the publication announcement
576
+
577
+ `src/api.ts` publishes at module load — importing `./extension.ts`, directly or through the package
578
+ root — so the API exists as soon as the package has been loaded. A compatible object already in the
579
+ slot is kept and returned untouched. An incompatible one is left exactly where it is (never
580
+ overwritten, deleted or reset); this copy's own object still works for registration and operates on
581
+ the same process-global bus (§7).
582
+
583
+ ### The load order, as observed rather than assumed
584
+
585
+ This design first claimed that `session_start` arrives only after every extension module has
586
+ initialized, so a plugin's handler either found the object or knew the bridge was absent
587
+ (`docs/extensions.md:213`). A real TUI disproved it: `/bridge-status` showed a working bridge
588
+ (`backend: capture`, `sidebar: intercepted`, `card style wiring: chat wrapped`) while the worked
589
+ example — a global extension in `~/.pi/agent/extensions` — reported "no compatible
590
+ gentle-shell-bridge is installed". Global extensions load, and receive `session_start`,
591
+ before a configured package's module publishes. The symbol was read too early, not missing.
592
+
593
+ So an absent slot at `session_start` means "not yet". Two mechanisms answer that, and they stay
594
+ separate on purpose:
595
+
596
+ 1. **the symbol** remains the only authority for *which* object to use (`isBridgeApi`, first wins);
597
+ 2. **`gentle-shell-bridge:ready`** tells the process *when* a publication happened. It is emitted
598
+ from `announceBridgeReady` at the extension factory — the first moment `pi.events` exists —
599
+ exactly once per process (a `globalThis` flag like every other piece of process state, §7), and
600
+ never when the slot holds an incompatible payload, because there would be nothing to look at.
601
+
602
+ A listener never receives the API: it waits for the signal and reads the symbol again, so the gate,
603
+ the first-wins rule and the "absent is absent" rule are unchanged. `onBridgeApi(handler, pi.events)`
604
+ is the helper form — immediate when the object is already published and a subscription when it is
605
+ not, with the subscription detaching itself after one delivery. Registration stays idempotent by
606
+ stable id, so a plugin that registers from `session_start` *and* from the announcement leaves one of
607
+ each surface, and the announcement can arrive before or after the session event without either path
608
+ having to know about the other.
609
+
610
+ What was rejected: a queue inside the bridge (a plugin's registrations waiting invisibly for a
611
+ version that may never load — the previous revision of this file described exactly that and removed
612
+ it), and retrying `session_start` on a timer (no event to hang it on, and the plugin would have to
613
+ keep state it does not need). The announcement is neither: one event, no holding area, and the
614
+ plugin decides what an absent-and-unannounced bridge means for it.
615
+
616
+ ### Why a path, and why an environment variable, were rejected
617
+
618
+ A path is not a contract between two independent projects: it names one checkout on one machine,
619
+ reaches into another package's files, and breaks silently the day either repository moves. An
620
+ environment variable is the same problem plus a per-user setup step, so the feature stops working
621
+ until someone is told to set it — not "zero extra steps". Both were in the worked example
622
+ (`bridgeCandidates()`, a dynamic `import()`, `GENTLE_SHELL_BRIDGE_MODULE`) and both are gone: the
623
+ example now inlines the symbol and the gate (`examples/bash-card.ts`) and registers from
624
+ `session_start`.
625
+
626
+ ### Version skew
627
+
628
+ An object that fails the gate — another library's payload, another major, a partial object — is
629
+ **absent**, never an error and never half-used: `bridgeApi()` returns `undefined`, the plugin
630
+ decides what to do, the bridge emits one warning per process, and the slot is left for whoever
631
+ wrote it. A newer plugin against an older bridge therefore sees "absent" instead of being
632
+ deferred silently; that compatibility cliff is intentional.
633
+
634
+ ## 14. The card-style transform
635
+
636
+ The third surface. Rows and sections add something to the menu and the rail; a card style cannot be
637
+ added at all in the way the others can — the host draws the conversation itself, `CARD_STYLE` is a
638
+ two-member union (`lib/shell-card.ts:33-38`) read in eight places, all binary, and
639
+ `cardStyle()` normalizes any unknown value to `float` (`:43-45`). So the bridge does the only thing
640
+ the host allows: it lets gentle-shell draw, then repaints the frames the host already produced.
641
+
642
+ ### Where the conversation component is, and how that was proven
643
+
644
+ The full derivation, with every citation, is the evidence block at the top of `src/card-styles.ts`
645
+ (§1). In short, from the `tui` Pi hands a `ctx.ui.setWidget` factory (`src/extension.ts:108-109`):
646
+
647
+ 1. **Primary:** `tui.children[0]` → the document container → `children[2]` (the chat container).
648
+ `children` is a declared member of the public `TUI` interface (`pi-tui/dist/tui.d.ts:223-231`)
649
+ and Pi fills it in both renderer modes, document first
650
+ (`pi-coding-agent/dist/modes/interactive/interactive-mode.js:623-625,722-731`); the document's
651
+ children are `[header, loadedResources, chatContainer]` (`:404-407`).
652
+ 2. **Secondary:** `tui.layoutRoot` → its layout entries → `entries[0].component`, the primary
653
+ `ScrollView` (`dist/modes/interactive/chat-viewport.js:4-11,22-25`) → that view's child through
654
+ the layout protocol (`pi-tui/dist/components/scroll-view.d.ts:57`,
655
+ `layout-node.d.ts:2,36-38`) → the same document. `layoutRoot` is TypeScript-`private` and exists
656
+ only on the fullscreen renderer (`tui-alt-screen.d.ts:44`), which is why it is second.
657
+ 3. Anything else is reported, never guessed: `no-tui`, `no-children`, `no-document`,
658
+ `no-conversation`, `render-missing`, `wrong-shape` (a host accessor that throws). `/bridge-status`
659
+ prints the outcome, so "no repaint" is always an answer.
660
+
661
+ ### The transform
662
+
663
+ `src/card-style-transform.ts` wraps that component's `render(width)`: the host's own render runs
664
+ first (a host failure stays the host's), then the rows are handed to the detector. It recognises
665
+ exactly the `float` grammar (`src/card-styles.ts` §2): a one-column margin, the `▎` bar, then
666
+ padding / header / optional separator / body / padding, with an empty-body card folded to three rows.
667
+ A `neon` card starts at column 0 with `╭`, `│` or `╰` and is never touched. Rows that are not cards
668
+ come back as the same strings; with `native`, no selection, or a selection naming a style that no
669
+ longer exists, the input array itself is returned. A style's three functions get the measured width
670
+ and the extracted heading; anything wider than the width is clipped before it is emitted, and the
671
+ frame is abandoned wholesale if any style call throws (a card is never half-repainted).
672
+
673
+ ### Wrapping once, and staying wrapped
674
+
675
+ `src/card-style-wiring.ts` is armed from the same widget factory that captures the `tui` — never at
676
+ module load — and follows the sidebar backend's discipline (`src/sidebar.ts:52-90`): an own-property
677
+ marker certifies the exact `render` function installed, so a rebind, a second `session_start` or a
678
+ second copy of this package reports `already-wrapped` instead of stacking wrappers. The wrapper
679
+ captures a generation; `disableCardStyleTransform()` restores the host's own `render`, deletes the
680
+ marker and bumps the generation, so a wrapper foreign code retained becomes a pass-through rather
681
+ than an injector. The same rule governs a retained *style*: a disposed style resolves to `missing`,
682
+ and `missing` is the pass-through.
683
+
684
+ ### The rail: a second surface, one transform
685
+
686
+ The rail frames its own cards — `sectionCardRows`/`headerParts` over `floatRows`/`cardTop`/
687
+ `cardLine` (`lib/shell-sidebar-layout.ts:36-47`), host sections and contributed sections through the
688
+ same call — and it frames them AFTER a part hands over its rows. A part therefore cannot restyle its
689
+ own card, and wrapping the contributed part would only ever reach the rows it contributed. The
690
+ reachable seam is one level up: the framed rows are the child of the rail's `ScrollView` in the
691
+ layout tree (`lib/shell-sidebar-layout.ts:486-499`; `ScrollView[LAYOUT_NODE]()` reports its child,
692
+ `pi-tui/dist/components/scroll-view.js:191-192`), so wrapping that component's `render(width)` sees
693
+ every rail frame the host produced — its own sections and ours, in one place.
694
+
695
+ `src/card-style-wiring.ts` walks that tree by SHAPE (the root's layout node → the `hstack` → its
696
+ second entry → that scroll view's child), never by index into the root, and requires the candidate to
697
+ expose both a layout protocol and a `render`. The wrapper is the conversation's wrapper with one
698
+ difference: the rail runs ONLY the card-style repaint. The registered transcript transforms are the
699
+ conversation's extension point (their contract is "the conversation's rows"); handing them the rail
700
+ would let a plugin corrupt a surface it never claimed. Every absence is a reported outcome
701
+ (`no-layout-root`, `no-sidebar`, `no-rail`), recorded per surface for `/bridge-status`.
702
+
703
+ ### A write repaints its surface
704
+
705
+ The rail and the conversation both display bridge-owned state: a section's visibility, and the
706
+ card-style selection. Persisting is not painting — gentle-shell re-renders a rail part only when its
707
+ digest changes or its layout revision moves (`lib/shell-sidebar-layout.ts:310-350`), so a
708
+ bridge-owned row used to change the store and leave the screen alone until something else repainted
709
+ it. `src/repaint.ts` performs the two steps gentle-shell's own part owners perform
710
+ (`lib/shell-sidebar-layout.ts:105-107`, `extensions/gentle-agents.ts:2147-2148`), synchronously,
711
+ inside the row's `action()`: bump `tui.terminal[CACHE].revision` (the documented
712
+ `invalidateSidebar(tui)` effect, reached through the host's published symbol because this package
713
+ cannot import the host's modules) and call `tui.requestRender()`. No timer, no poll, no retry, and no
714
+ frame when the write changed nothing. An unavailable path is recorded and reported, not swallowed.
715
+
716
+ ### The conflict that cannot be won
717
+
718
+ A post-processor downstream of this bridge can wrap the chat container's `render` and strip any box
719
+ chrome it finds — a class of consumer that re-frames the conversation, and the frame that reaches
720
+ the screen is the last one written. That can erase this frame on exactly the cards it re-frames —
721
+ bash in particular. The bridge cannot prevent that, does not try, and says so in `/bridge-status`:
722
+
723
+ > a downstream post-processor can strip or override this frame; the bridge cannot prevent it.
724
+
725
+ Nothing user-visible names such a consumer: the bridge ships to whoever installs it, and no package
726
+ it cannot see is allowed to be a prerequisite for its own report.
727
+
728
+ ### Why not ask gentle-shell to add a style registry
729
+
730
+ Rejected in [state.md](state.md) (§Alternatives rejected, F/G). The package exists because the host
731
+ does not take every change; a design that waits for one is not a bridge.
732
+
733
+ ## 15. The transform pipeline
734
+
735
+ The fourth surface. §14 explains how the bridge reaches the conversation component; this section
736
+ is what that wrapper does with the rows it intercepts, now that it serves more than one feature.
737
+
738
+ ### One wrapper, an ordered pipeline inside it
739
+
740
+ `src/card-style-wiring.ts` installs a single `render(width)` wrapper on the conversation component.
741
+ Inside it the host's own render runs first (a host failure stays the host's), then:
742
+
743
+ 1. `transformCardLines` (`src/card-style-transform.ts`) repaints the selected card style, or returns
744
+ the host's array unchanged when there is nothing to do;
745
+ 2. `applyTransforms` (`src/transform-pipeline.ts`) runs every registered transform in order, handing
746
+ each one the previous output and clipping what comes back to the width the host passed;
747
+ 3. the result goes to Pi.
748
+
749
+ With no style selected and no transform registered, both steps return their input, so the host's own
750
+ array instance reaches Pi: the identity the card styles already promised is now the whole pipeline's
751
+ promise. The wrapper keeps the generation guard and the own-property marker unchanged, so a rebind, a
752
+ second `session_start` or a second package copy still resolve to `already-wrapped` instead of
753
+ stacking pipelines.
754
+
755
+ ### Why the loop belongs to the bridge, not to each plugin
756
+
757
+ `pi.registerToolRenderer` resolves when a tool row is BUILT (`docs/extensions.md`, "Custom
758
+ Rendering"), so a rendering choice made through that slot can only reach the next row. Pi exposes no
759
+ API to repaint rows that are already drawn, and the component that renders them is not reachable
760
+ from a row's `action()`. The bridge already wraps that component for card styles, so the transform
761
+ layer is a widening of a proven path rather than a new interception: one loop, one place to clip, one
762
+ place to isolate a throw, and one place `/bridge-status` reads.
763
+
764
+ A transform draws what the host already drew, so history and new rows are one picture. A tool
765
+ DESIGN (§16) is the other half of the same idea: the bridge owns Pi's one renderer resolver, hands a
766
+ design the rows the host's own card produced, and reads the live selection on every render pass, so
767
+ a design chosen after a card was drawn repaints that card instead of waiting for the next one.
768
+
769
+ ### The cost of a switch, and why it is one plain frame (S13)
770
+
771
+ The live-selection rule is also what makes an invalidation unnecessary. The wrapper renders the
772
+ host's component and applies the selection to the rows it answered with, and the host's own card
773
+ cache (`ToolCardRows`, `cards-bash-v2` `extensions/quiet-tools.ts:612-624`) sits BELOW that wrapper:
774
+ it keys its frame on width, the host's style and whether the card is running, never on a design, so a
775
+ cached hit still reaches the design on every pass. A switch therefore needs no cache dropped: it is
776
+ one differential `requestRender()`, which is what the host's own `Card style` switch does
777
+ (`extensions/gentle-shell.ts:3409-3412`).
778
+
779
+ T4 originally made `requestRepaint()` a full repaint — `tui.invalidate()` then
780
+ `requestRender(force = true)` — because a setting seemed unable to reach a drawn card otherwise. On a
781
+ real transcript that is the freeze the user reported: at 600 children the invalidate re-parses 300
782
+ Markdown messages (730 ms for the frame against 21 ms without it) and the forced frame clears the
783
+ screen and scrollback and rewrites the whole buffer (6.9 MB). Numbers and the exact scenario are in
784
+ [contract.md](contract.md) §14, "What a switch costs"; `test/tool-design-cost.test.ts` bounds the
785
+ result by call counts — no tree invalidation, no prose re-parse, one differential frame, at most one
786
+ selection read per card per frame — so the bound cannot flake on a busy machine. Nothing else was
787
+ changed: the persisted selection is still read per card per frame (3.5 µs, measured) and adoption
788
+ still invalidates each card once when it wraps it.
789
+
790
+ S19 applied the same measurement to the second path. `requestSurfaceRepaint()` — a bridge-owned row,
791
+ such as a bridge card style or a section toggle — no longer invalidates the tree or forces the frame;
792
+ the `/bridge-status` wording is unchanged (the cause line never claimed a full repaint). Per-surface
793
+ proof and numbers are in [contract.md](contract.md) §14, "What a bridge-owned switch costs". The reason
794
+ it is safe is the same ordering: a bridge style is applied ABOVE the host's own frame caches, so a cached
795
+ host frame is restyled rather than frozen, and the host's rail memo already keys on the section digest.
796
+
797
+ ### Ordering, isolation and the width
798
+
799
+ `snapshotTransforms()` decides the sequence once per frame, with the same rule as sections and card
800
+ styles: an explicit `order` first, ascending, then registration order. A transform that throws, or
801
+ that returns something which is not an array of strings, is skipped for that frame — the lines in
802
+ force are kept and the remaining transforms still run — and the failure is recorded with its id and
803
+ message for `/bridge-status`. Whatever comes back is clipped with `clipToWidth`, so a transform
804
+ cannot make the frame wider than the terminal — and, just as importantly, a row that already fits
805
+ the host's own measure is returned byte-identical: the pipeline never rewrites lines a transform did
806
+ not change (a no-op transform hands back the same array, and a fitting row is never measured into a
807
+ cut). The measure is `hostColumns` (TAB three columns, a grapheme cluster as a unit, CSI/OSC/APC
808
+ zero columns, so a hyperlink contributes its text and not its URL); a row that must be cut is cut
809
+ between clusters, with an open OSC-8 link closed and an active SGR reset so the shortened row cannot
810
+ leak either into the rows after it.
811
+
812
+ ### Telemetry: the counts that distinguish "missed" from "never ran"
813
+
814
+ The pipeline records, per transform and per frame, what the frame did and what the transform saw:
815
+ the outcome, the number of lines it received, and whatever the transform reported through
816
+ `ctx.report(...)` — candidates examined, cards recognised, cards rewritten, and the shapes that
817
+ matched, by name (`src/transform-pipeline.ts`, `TransformFrameCounts`). It exists because a real TUI
818
+ could not tell two failures apart: a wrapper that never reaches the conversation, and a grammar that
819
+ reaches it and recognises nothing. Those two read differently in the record — zero lines received
820
+ versus candidates without recognition — without another round of guessing.
821
+
822
+ Two rules make the record safe to keep. **Counts only:** a frame carries the user's private output,
823
+ so no line and no text is stored; numbers are clamped to non-negative integers and a shape name is
824
+ kept only when it is a name (lowercase letters, digits, `_`, `.`, `-`, at most 24 characters, at most
825
+ 12 per frame), which means a line cannot be smuggled into the record through that field. **Nothing
826
+ it does reaches the frame:** the counts are collected in the call that already produces the output
827
+ (no second pass), and every step of recording is guarded, so a transform that reports nonsense, or
828
+ none at all, loses counts and never a frame. `/bridge-status` prints the counts on the transform's
829
+ own line, and the block is still absent entirely when no transform is registered.
830
+
831
+ A transform that reads `float` cards and the host's `neon` outline reports those two names (the
832
+ bridge's card-style repaint is `float`-only, so a neon card reaches a transform untouched), so the
833
+ next step after a real-TUI report is data-driven: a `skipped` line with candidates and no
834
+ recognition names a grammar gap, and recognition with `0 rewritten` names a setting.
835
+
836
+ ## 16. The tool-design bus (S4–S7)
837
+
838
+ ### One resolver, because Pi composes
839
+
840
+ `pi.registerToolRenderer(resolver)` (`dist/core/extensions/types.d.ts:546`) is the sanctioned way to
841
+ choose how a tool's card is drawn. Resolvers COMPOSE — Pi chains every extension's resolver
842
+ (`dist/core/extensions/runner.js:550-553`) — so two plugins that each called it would each wrap the
843
+ other's component and neither could see the other's choice. The bridge therefore registers exactly
844
+ ONE resolver while arming (`installToolDesignResolver`, from the same `session_start` that arms the
845
+ rows and the sidebar) and returns `next()`'s renderers untouched for a tool with no design.
846
+
847
+ ### The wrapper keeps the frame
848
+
849
+ For a tool that HAS at least one design the resolver returns a copy of the host's renderers whose
850
+ `renderCall`/`renderResult` wrap the component the host built. Every other member — `renderShell`
851
+ included, which decides whether the host frames the card itself — is the host's own value. The
852
+ wrapper's `render(width)` is:
853
+
854
+ ```
855
+ hostComponent.render(width) → design.apply(rows, { width, tool, theme, part }) → rows
856
+ ```
857
+
858
+ so the chrome (`neon`, `float`, whatever the host's Card style is) exists before the design sees
859
+ anything. `invalidate()` and the optional mouse/key-release members are delegated, so the card keeps
860
+ behaving exactly as the host's did — only the rows can differ.
861
+
862
+ `part` is `"call"` when this row set came from `renderCall` and `"result"` when it came from
863
+ `renderResult` (S12). The two members are wrapped SEPARATELY, so the design is told which half of the
864
+ card it is drawing instead of having to infer it from the rows: the host draws both with the same
865
+ chrome, and a float result row that begins with `$ ` is indistinguishable from a float heading.
866
+
867
+ Wrapping even when nothing is selected is deliberate. Pi resolves the renderers ONCE per card, when
868
+ it constructs it (`interactive-mode.js:1816-1818,2957-2961,3028-3032,3376-3380`), and re-renders that
869
+ same component on every frame. A card built while the design was unselected must still be able to
870
+ follow a later selection, so the wrapper reads the LIVE selection on every pass; with no selection
871
+ (or a selection whose design was disposed) it returns the host's own array instance, byte for byte.
872
+
873
+ ### The `Cards` line is `Card <tool>`
874
+
875
+ The per-tool selection is a customize line in `Cards`, and the category already names its lines
876
+ `Card <what>` (`Card style`, `Card content`), so a design line follows the same vocabulary rather
877
+ than showing a bare tool name, which reads like a category of its own. `src/tool-design-rows.ts`
878
+ builds it: `Card` plus the tool's readable words (`deriveRowLabel`), at most two of them because the
879
+ row-name rule is one to three plain words (`src/row-names.ts`). A long name keeps its TAIL —
880
+ `mcp__engram__mem_save` reads `Card mem save`, because the leading words are the same for every tool
881
+ of that server — a name with no words reads `Card tool`, and two tools that clamp to the same name
882
+ are told apart with an ordinal (`Card mem save`, `Card mem 2`) exactly the way sections are, so the
883
+ result still passes the shape rule. Nothing persisted moves with the name: the store is keyed by the
884
+ tool name and a value is the design's id, so the line can be renamed without losing a choice.
885
+
886
+ ### When an earlier resolver answers first (S10)
887
+
888
+ Pi composes resolvers in extension load order (`runner.js:550-553`); `loader.js:298-302` only pushes,
889
+ so there is no reordering API. A resolver loaded before this package that answers WITHOUT calling
890
+ `next()` — the host's quiet bash resolver while `Card content` is `minimal`
891
+ (`extensions/quiet-tools.ts:910-914`) — hides `toolDesignResolver` completely. The user's install has
892
+ gentle-pi first and this bridge last, which is why a resolver-only design never reached a card
893
+ (L3/E6).
894
+
895
+ `src/tool-design-adoption.ts` is the second path. The conversation wrapper from
896
+ `src/card-style-wiring.ts` already holds Pi's chat container, whose children are
897
+ `ToolExecutionComponent`s; each carries `toolName` and the `toolDefinition` Pi resolved
898
+ (`tool-execution.js`, constructor near line 37-45). On every conversation render, BEFORE the host
899
+ renders its children, the adoption pass looks at each child: a string `toolName` with at least one
900
+ registered design is adopted by replacing its `toolDefinition` with the same `wrapRenderers` wrapper
901
+ the resolver path uses, then invalidating the card so its stored renderer re-runs through the design
902
+ (`invalidate()` → `updateDisplay()`, `tool-execution.js:64-68,164-175`). `wrapRenderers` stamps every
903
+ wrapper with `TOOL_RENDERERS_WRAPPED`, and both paths check it, so a card the resolver already
904
+ wrapped is reported `already` and never wrapped twice. New cards (`/tree`, reload, session switch)
905
+ are new children and are adopted on the next pass, and because the wrapper reads the live selection
906
+ on every frame, `native` and a later selection both apply to the same already-constructed card (S2).
907
+
908
+ Every absence is reported: a child that is not a card, a `toolName` that is not a string and a tool
909
+ with no designs are ignored; a missing, non-object, unwrappable or unwritable `toolDefinition` is
910
+ recorded as an `(adoption)` failure and the host's own card is drawn; a throwing `invalidate` is
911
+ recorded once and the wrapped definition still applies on the next frame. The pass never throws into
912
+ Pi's render loop.
913
+
914
+ ### Per tool name, never per bash
915
+
916
+ `src/tool-designs.ts` keeps one registry per tool name and nothing else: no known-tool list, no
917
+ branch on a name. `src/tool-design-rows.ts` turns each tool with designs into one `Cards` line —
918
+ the host's own card (`native`) plus one value per design, in registration order — and persists the
919
+ selection per tool in the bridge's own `bridge-tool-designs` store, never in the host's config. The
920
+ stored value is the design's `id`, so renaming a label never moves the bullet; a selection whose
921
+ design was disposed resolves to `missing` and draws the host's card.
922
+
923
+ Registration goes through the same attribution as every other surface (`src/plugin-lines.ts`,
924
+ `PluginSurface` includes `"tool design"`), so a plugin that contributes ONLY designs still gets its
925
+ one INTEGRATIONS line and the same accepted/rejected counts.
926
+
927
+ ### What it reports
928
+
929
+ `/bridge-status` lists every tool with designs, its live selection, whether the wrapper has mounted
930
+ a card for it (`wrapper mounted on 2 cards` — the answer to "is this card even wrapped?" via the
931
+ resolver), how many cards the adoption pass reached (`adopted 1 card` — the answer when a resolver
932
+ ahead of the bridge answered first), the conflicts a replacement or a duplicate label produced, and
933
+ the designs that threw. Failures are deduplicated by (tool, id, message): a design that throws on
934
+ every frame is one fact, not one line per frame.
935
+
936
+ ## Limits
937
+
938
+ - **Internals, not an API.** Everything here depends on one constructor line, one adapter
939
+ shape and one call chain in code gentle-shell does not export. No upstream row-provider
940
+ hook exists today.
941
+ - **The hook backend is unverified.** `Symbol.for("gentle-pi.customize-row-providers")` is
942
+ accepted as a plain array or an object with a callable `register`, and anything else is
943
+ reported as unusable so the capture backend takes over. No host has been observed emitting
944
+ that registry, so the pushed provider's shape (`{ id, rows }`) is an inference.
945
+ - **`Array.prototype.map` is patched**, briefly: one synchronous factory call, restored in
946
+ `finally`. A callback that stores the patched function for later use is out of scope;
947
+ nothing in the observed chain does that.
948
+ - **Capture is heuristic.** A menu that builds rows through a different array (or not at
949
+ all) yields no capture. Reported, never silent — but not fixed.
950
+ - **A new category depends on the capture proof.** The renderer types `category` as a closed union
951
+ (`lib/visual-customize-view.ts:105`) but derives its pages from the rows at runtime, so a new
952
+ category appears only when the early injection (see "A brand-new page") ran; a host that renames
953
+ its default page, or a menu the proof cannot prove, gets the late path and no new page. `Keymaps` is
954
+ reserved.
955
+ - **Tests exercise a fake host, not the TUI.** The suite runs under `node --test`; the end-to-end
956
+ ones drive a fake host that models `gentle-shell.ts:3550-3562` and a fake sidebar that models
957
+ `lib/shell-sidebar.ts` + `lib/shell-sidebar-layout.ts`'s card frame. The capture technique itself
958
+ is verified in the real TUI only by the reference probe; the sidebar section surface has never
959
+ been seen in one.
960
+ - **Sections cannot be interleaved.** They render after every native card, by construction (see
961
+ §12); a request to slot one between `CHANGES` and `STATS` is not supported.
962
+ - **The sidebar widget's visual footprint is unconfirmed** (see §12): the `tui` is acquired through
963
+ a widget factory, and whether an empty component is invisible has not been observed.
964
+ - **The card-style repaint is proven against a fake conversation, not a live one** (see §14). The
965
+ fixture rows are real `lib/shell-card.ts` output, but the component tree is Pi's documented shape,
966
+ and the downstream-override conflict (§14) cannot be reproduced without a second post-processor
967
+ running in a real session.
968
+ - **A blank body row is byte-identical to a card's opener/closer** (`cardLine("")` is a bar and
969
+ spaces), so a card containing a blank body row is detected as ending at that row; the remaining
970
+ rows stay as the host drew them. A heading's hint is only split out when its style runs prove it,
971
+ and the heading glyph is stripped by a one-character rule. Both are documented in the transformer
972
+ and are the price of not parsing the host's intent.
973
+ - **Async factories are reported, not supported.** `Promise.resolve(factory(...))` accepts a
974
+ promise (`interactive-mode.ts:3136`), but the capture window closes with the synchronous
975
+ call. A promise-returning factory is returned untouched and recorded as
976
+ `uncaptured (async-factory)`, so no rows are injected; this is a test case, not a fix.
977
+ - **The runtime handshake is proven in-process.** The suite resets `globalThis`, simulates an
978
+ early loader and loads the real example more than once under `node --test`; two live Pi
979
+ extensions loading in either order inside one running session have not been observed, and
980
+ neither has a real second major version at the slot (the skew branch is driven by a fake
981
+ object).