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.
- package/.codegraph/.gitignore +5 -0
- package/PRD.md +144 -0
- package/README.md +745 -2
- package/docs/architecture.md +981 -0
- package/docs/contract.md +1266 -0
- package/docs/state.md +251 -0
- package/examples/bash-card.ts +1694 -0
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +30 -0
- package/odd/tasks/plugin-keymap.md +32 -0
- package/odd/tasks/plugin-surface-contract.md +55 -0
- package/package.json +35 -4
- package/src/api.ts +492 -0
- package/src/arm.ts +83 -0
- package/src/bus.ts +558 -0
- package/src/capture.ts +886 -0
- package/src/card-style-line.ts +112 -0
- package/src/card-style-rows.ts +390 -0
- package/src/card-style-transform.ts +862 -0
- package/src/card-style-wiring.ts +472 -0
- package/src/card-styles.ts +159 -0
- package/src/config.ts +71 -0
- package/src/extension.ts +271 -0
- package/src/hook.ts +310 -0
- package/src/index.ts +71 -0
- package/src/notices.ts +137 -0
- package/src/plugin-lines.ts +865 -0
- package/src/plugin-surface.ts +218 -0
- package/src/presence.ts +259 -0
- package/src/repaint.ts +351 -0
- package/src/row-labels.ts +146 -0
- package/src/row-names.ts +273 -0
- package/src/row-origin.ts +144 -0
- package/src/rows.ts +248 -0
- package/src/section-panel.ts +378 -0
- package/src/section-pointer.ts +164 -0
- package/src/sections-rows.ts +128 -0
- package/src/sidebar-render.ts +535 -0
- package/src/sidebar.ts +919 -0
- package/src/status.ts +1161 -0
- package/src/store.ts +279 -0
- package/src/tool-design-adoption.ts +263 -0
- package/src/tool-design-rows.ts +246 -0
- package/src/tool-design-wiring.ts +428 -0
- package/src/tool-designs.ts +402 -0
- package/src/transform-pipeline.ts +351 -0
- package/src/types.ts +442 -0
- package/src/visibility.ts +154 -0
- package/test/api.test.ts +395 -0
- package/test/apply-on-move.test.ts +477 -0
- package/test/arm.test.ts +220 -0
- package/test/bridge-ready.test.ts +309 -0
- package/test/bus.test.ts +165 -0
- package/test/button-rows.test.ts +409 -0
- package/test/capture.test.ts +356 -0
- package/test/card-style-rows.test.ts +510 -0
- package/test/card-style-transform.test.ts +379 -0
- package/test/card-style-wiring.test.ts +258 -0
- package/test/card-styles-e2e.test.ts +491 -0
- package/test/card-styles.test.ts +324 -0
- package/test/card-transcript-path.test.ts +178 -0
- package/test/config.test.ts +93 -0
- package/test/e2e.test.ts +443 -0
- package/test/example-bash-identity.test.ts +372 -0
- package/test/example-bash-matrix.test.ts +856 -0
- package/test/example-designs.test.ts +734 -0
- package/test/example-neon-transform.test.ts +283 -0
- package/test/example-plain-style.test.ts +1268 -0
- package/test/extension.test.ts +160 -0
- package/test/fake-events.ts +58 -0
- package/test/fake-host.ts +211 -0
- package/test/fake-sidebar.ts +516 -0
- package/test/fake-transcript.ts +486 -0
- package/test/frame-telemetry.test.ts +417 -0
- package/test/hook.test.ts +196 -0
- package/test/manifest.test.ts +105 -0
- package/test/new-category.test.ts +979 -0
- package/test/notices.test.ts +328 -0
- package/test/plugin-integration-lines.test.ts +979 -0
- package/test/plugin-surface.test.ts +152 -0
- package/test/presence.test.ts +459 -0
- package/test/rail-surfaces.test.ts +457 -0
- package/test/rail-width.test.ts +423 -0
- package/test/regression.test.ts +483 -0
- package/test/repaint-api.test.ts +200 -0
- package/test/reserved-categories.test.ts +361 -0
- package/test/row-labels.test.ts +284 -0
- package/test/row-names.test.ts +473 -0
- package/test/row-origin.test.ts +395 -0
- package/test/section-interaction.test.ts +364 -0
- package/test/section-panel.test.ts +403 -0
- package/test/sections-rows.test.ts +291 -0
- package/test/sidebar-backend.test.ts +471 -0
- package/test/sidebar-e2e.test.ts +331 -0
- package/test/sidebar-hover.test.ts +276 -0
- package/test/sidebar-independent.test.ts +349 -0
- package/test/sidebar-render.test.ts +281 -0
- package/test/sidebar-sections.test.ts +245 -0
- package/test/status-card-styles.test.ts +201 -0
- package/test/status-section-frame.test.ts +181 -0
- package/test/status-sections.test.ts +243 -0
- package/test/status.test.ts +209 -0
- package/test/store.test.ts +192 -0
- package/test/surface-repaint-cost.test.ts +520 -0
- package/test/tool-design-chain.test.ts +483 -0
- package/test/tool-design-cost.test.ts +473 -0
- package/test/tool-design-rows.test.ts +359 -0
- package/test/tool-designs.test.ts +745 -0
- package/test/transform-pipeline.test.ts +465 -0
- package/test/transforms-bus.test.ts +233 -0
- package/test/verifier-width-regressions.test.ts +164 -0
- 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).
|