gentle-shell-bridge 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,746 @@
1
- # Temporary Holding Version
1
+ # gentle-shell-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A shared, plugin-facing bridge between Pi plugins and gentle-shell's `/gentle:customize`
4
+ menu. A plugin publishes a *row provider*; the bridge owns the single interception layer
5
+ and fans out to every provider, so N plugins × M gentle-shell surfaces becomes N providers
6
+ and one interception. Plugins stop wrapping `ctx.ui` themselves, and stop colliding with
7
+ each other when more than one wants to add a settings row.
8
+
9
+ **Status: released as `0.3.0`.** The package exists and runs. It is a manifest-backed Pi
10
+ package (`pi.extensions` → `./src/extension.ts`) loaded from TypeScript sources through
11
+ jiti, so there is no build step and no `dist/`; it registers a `bridge-status` command and
12
+ carries a `node --test` suite of 673 tests, 26 of them end-to-end through the public API and
13
+ the real extension entry, a matrix driving the worked example through the real wiring (its design,
14
+ the restored `plain` card style, the rail and its sections), and 22 pinning the publication
15
+ announcement in both load orders. The capture
16
+ technique was proven in the real TUI by a 76-line
17
+ throwaway extension (`loon-customize-probe.ts`); the capture backend implements it, while
18
+ the hook backend is implemented but **unverified** — no host has been observed emitting the
19
+ registry it looks for. The sidebar surface is proven against a fake rail, not in a live TUI:
20
+ what is not proven is listed under **Limits**.
21
+
22
+ - [PRD.md](PRD.md) — problem, users, requirements, success criteria
23
+ - [docs/architecture.md](docs/architecture.md) — how the host menu is reached, with source citations
24
+ - [docs/contract.md](docs/contract.md) — `registerRows`, provider and row shapes, diagnostics
25
+ - [docs/state.md](docs/state.md) — ADR: stateless composition plus optional per-plugin store
26
+
27
+ ## Worked consumer
28
+
29
+ [`examples/bash-card.ts`](examples/bash-card.ts) is a standalone Pi extension that demonstrates
30
+ every surface this feature exists for: a **tool design** through `registerToolDesign`, a **card
31
+ style** through `registerCardStyle`, a **`BASH CARD` sidebar section** through `registerSection`,
32
+ and a **demo category** through `registerRows`. The section is toggled from **`Sections`** in
33
+ `/gentle:customize` with no setting, row or store written by the plugin; the design and the style
34
+ add their own values to the `Cards` page.
35
+
36
+ The design is the one the user asked for. gentle-shell draws a minimal bash card with just a `$`
37
+ mark; the design heads that same card with the word `bash`, and nothing else:
38
+
39
+ ```
40
+ before ▎ $ npm test (timeout 30s) ctrl+o to expand
41
+ after ▎ bash npm test (timeout 30s) ctrl+o to expand
42
+ ```
43
+
44
+ The card is still the host's card. A design receives the lines the HOST's own renderer produced and
45
+ returns the lines to draw, so whatever `Card style` is selected survives by construction: with
46
+ `neon` the card stays neon, with `float` it stays float. The example changes exactly two things on
47
+ the heading row — the mark, and the fill it gives back so the row keeps the host's width — and every
48
+ other byte of every other row (ANSI, padding, hint, rails, body) comes back identical:
49
+ `test/example-bash-identity.test.ts`, `test/example-neon-transform.test.ts` and
50
+ `test/example-bash-matrix.test.ts` assert that byte for byte.
51
+
52
+ ### The whole matrix: every card style × every card content (S11)
53
+
54
+ The user's requirement, verbatim:
55
+
56
+ > tambien las tools redisenada (en este caso tool bash) personalizadas deberia ser compatibles con
57
+ > card style float/neon (o agregados) y card content default/minimal
58
+
59
+ `test/example-bash-matrix.test.ts` drives the registered design through the real bus over every cell,
60
+ and every fixture it uses was produced by running the installed host's and pi's own code — the
61
+ derivation, file and line per fixture, is the comment block in `test/fake-transcript.ts`:
62
+
63
+ | card style | card content | the heading row the host draws | the design's row | reachable? |
64
+ | ---------- | ------------ | ------------------------------ | ---------------- | ---------- |
65
+ | `float` | `minimal` | ` ▎ $ npm test (timeout 30s) …… ctrl+o to expand ` | ` ▎ bash npm test (timeout 30s) …… ctrl+o to expand ` | yes — `extensions/quiet-tools.ts:910-914` |
66
+ | `neon` | `minimal` | `╭─ $ npm test (timeout 30s) ────── ctrl+o to expand ╮` | `╭─ bash npm test (timeout 30s) ────── ctrl+o to expand ╮` | yes — same renderer, `lib/shell-card.ts:105-108` |
67
+ | `float` | `default` | ` $ npm test (timeout 30s)` inside pi's `Box` | ` bash npm test (timeout 30s)` | yes — pi's native renderer (`core/tools/renderers/bash.js`) |
68
+ | `neon` | `default` | identical to the cell above | identical | yes, and the same rows: nothing on pi's path reads `cardStyle()` |
69
+ | ADDED (`registerCardStyle`) | `minimal` | the float rows, repainted by `src/card-style-transform.ts` | the design's word survives the repaint | yes |
70
+ | ADDED (`registerCardStyle`) | `default` | pi's native rows | unchanged | **no** — the repaint's grammar is float's ` ▎ ` (`src/card-style-transform.ts:189-196`), so a registered style cannot reach pi's native card |
71
+
72
+ How the design finds the heading, without parsing frame chrome it does not own:
73
+
74
+ 1. the heading is the component's first row that carries a letter, a digit or the mark;
75
+ 2. the mark must be the row's first non-decoration character, followed by a space — `PATH=$HOME` and
76
+ `$(date)` are never prompts;
77
+ 3. no blank row may precede it: pi's native RESULT component opens with a `Spacer` row, and that one
78
+ row is what tells a native result from a native call;
79
+ 4. the row may not begin with the outline frame's side or closing rule (`│`, `╰`): a heading is on
80
+ the card's top, so a `$` on an outline body row is output;
81
+ 5. the word's three extra columns come out of the row's widest padding/rule fill, so the row keeps
82
+ the host's width exactly; when no run is wide enough — a command the host already collapsed to
83
+ fill the row — the three columns come out of that HOST-MADE ABBREVIATION instead: only text the
84
+ host already shortened loses three more columns, at a grapheme boundary, keeping the `…`. A
85
+ command the host shows in full is never shortened, because a design must not hide what a command
86
+ does; that row is left as the host drew it and reported;
87
+ 6. the design acts on the CALL only, because the bridge tells it which half of the card it is drawing
88
+ (`ctx.part`). In the `float` cell the call and the result are separate components drawn with the
89
+ same chrome, so a RESULT whose first body row begins with `$ ` (a command echoing a prompt) sits
90
+ exactly where a heading sits — with `part` it is returned byte-identical, and the other two cells
91
+ exclude it independently (blank row / `│` side).
92
+
93
+ **Limits, stated rather than hidden.** (a) A command containing a wider run of spaces than the
94
+ frame's own fill would have those spaces shortened instead of the fill. (b) The heading must be in
95
+ the first row of content: a host that puts a second row of chrome above its heading would be
96
+ reported as unrecognised, not guessed at. (c) The abbreviation has to be able to pay exactly three
97
+ columns in whole graphemes: a clipped run of two-column glyphs with no one-column glyph at its end
98
+ is refused rather than widened or cut in half. And the rule that matters most: **an unabbreviated
99
+ command with no fill is left as `$` and reported**, never shortened.
100
+
101
+ **A frame the design cannot prove** comes back byte-identical and is reported once: the `CARD BASH`
102
+ section carries an `unrecognised <reason>` row (`none` until it happens) and the same reason is
103
+ delivered once through `ctx.ui.notify`. That is the honest answer the earlier iterations were missing
104
+ — a design that recognises nothing now says so instead of looking installed.
105
+
106
+ The design is registered for the tool name `bash`, and the `Cards` line it produces appears with no
107
+ extra wiring and is named after the `Cards` category, like `Card style` and `Card content`:
108
+ `Card bash ○ native ○ bash heading`. The first value is the bridge's own `native` — the host's card,
109
+ the default — so installing the file changes no rendering until the user picks the design. Choosing it repaints the bash cards already on screen, not only the next one, because the
110
+ bridge reads the live selection on every render pass. A `read` card is never touched: designs are
111
+ declared per tool name, and the bus has no bash-specific branch.
112
+
113
+ The **`CARD BASH`** section is the second surface: the host draws the card and owns the collapse
114
+ control, and the card's right-aligned `meta` is the live selection for `bash` — the surface that
115
+ survives a collapse, and the only place the section shows it, so the value is never printed twice.
116
+ The body reports whether this load's design reached the bridge (`resolver registered` /
117
+ `resolver absent`), whether the restored `plain` style did (`card style registered` /
118
+ `absent`), and whether the design could recognise the frames it was handed
119
+ (`unrecognised none`, or the reason). It needs no
120
+ extra wiring either: registering the section is what adds its `Sections` toggle; the section's row
121
+ reads `card bash`, and the plugin's INTEGRATIONS line reads `Card bash`.
122
+
123
+ ### A card style of the example's own: `plain`
124
+
125
+ The fourth surface is the bridge's oldest one, restored with the SAME id (`bash-card.plain`), label
126
+ and literal preview it had before the design rewrite: `registerCardStyle` adds `plain` to the
127
+ `Card style` line, so it reads `○ neon ○ float ○ plain`. Selecting it repaints every TOOL card the
128
+ host drew with one gutter character per row, the heading as plain text and no box; selecting `neon`
129
+ or `float` hands the host its own bytes back, because pressing a host value clears the bridge's
130
+ selection to `native`.
131
+
132
+ The style is chrome only — it never parses a card. The bridge measures the host's card, extracts the
133
+ heading and the hint and hands them over. That is also why the two surfaces COMPOSE rather than
134
+ compete: the design runs first, on the host's rows, and the style repaints those rows after. With
135
+ `bash heading` selected the `plain` card still heads with the word `bash`:
136
+
137
+ ```text
138
+ before ▎ $ npm test (timeout 30s) ctrl+o to expand
139
+ ▎
140
+ ▎ PASS 42 tests
141
+ ▎
142
+
143
+ plain ▌ bash npm test (timeout 30s) ctrl+o to expand
144
+ ▌ PASS 42 tests
145
+ ▌
146
+ ```
147
+
148
+ `test/example-plain-style.test.ts` drives the whole matrix through the real wiring — the bridge's
149
+ own card-style transform, its rail wiring, the host's menu rows, the example's registration and the
150
+ host's own `Card style` values — and pins the cells that are NOT reachable instead of pretending
151
+ they are. In particular, card content `default` is pi's native card, not a float frame: neither a
152
+ host style nor a registered one can reach it, and the test asserts that pass-through by array
153
+ identity and in `/bridge-status`. The same file covers the rail (host sections such as `STATS`, the
154
+ bridge's own sections and a section added by another plugin, collapsed and expanded), and that
155
+ switching back to `neon`/`float` restores the host's exact bytes on every surface.
156
+
157
+ ### A category, a toggle and a button of the example's own
158
+
159
+ The example also proves the third surface: through the bridge's public `registerRows`, ONE provider
160
+ (stable id `bash-card.demo`, replaced in place on `/reload`) adds a **new category** that no host
161
+ code had to know about — `Card bash` — holding exactly two rows as the user reads them:
162
+
163
+ ```text
164
+ Card bash
165
+ Demo toggle ● on ○ off ← two consecutive rows sharing one choice.setting
166
+ Demo button ← a plain action row, no choice
167
+ ```
168
+
169
+ The toggle and the button are deliberately trivial, and their effect is visible without opening a
170
+ file: the example's own store (plugin id `bash-card`, not the bridge's selection file) holds
171
+ `demoToggle` (default `off`) and `demoPresses` (default `0`), and the `CARD BASH` section reads both
172
+ live:
173
+
174
+ ```text
175
+ demo toggle on
176
+ demo presses 3
177
+ ```
178
+
179
+ `demo toggle` is an inert row. `demo presses` is a CLICKABLE action row, drawn the way the host draws
180
+ its own: the label on the left and the count right-aligned. Clicking it increments the counter once,
181
+ and hovering it paints it with the host's own hover role, exactly as `INTEGRATIONS` and `CHANGES` do —
182
+ the worked example is how you check that the bridge's hover works in your terminal. Choosing `on`
183
+ persists the toggle; Enter on `Demo button` in the demo category increments the same counter. Each
184
+ demo row carries a
185
+ short literal preview. A bridge old enough not to publish `registerRows` degrades without throwing:
186
+ no demo category, while the design and the section keep working.
187
+
188
+ Copy the file into `~/.pi/agent/extensions/` and `/reload` to load it; the `bash` line defaults to
189
+ the host's own card, while the section is visible immediately (`defaultVisible: true`). It does not
190
+ import the package and does not know where it is installed: the example and the bridge meet on
191
+ `globalThis` behind one symbol (see **Reaching the bridge without importing it**). Global extensions
192
+ load before configured packages, so the example does not assume the bridge got there first: it tries
193
+ the symbol in its own `session_start` handler **and** listens for the bridge's
194
+ `gentle-shell-bridge:ready` announcement, whichever comes first registering the same surfaces
195
+ (registration replaces by stable id, so the second one adds nothing). When neither has happened yet,
196
+ the notice says which state the load is in — waiting for an announcement on a Pi with an event bus,
197
+ or nothing to wait for on one without — instead of claiming the bridge is not installed.
198
+
199
+ A bridge that publishes something else — another major version, a partial object, another library's
200
+ payload at the symbol — is treated as absent, never as an error: the example never calls an object it
201
+ cannot trust, registers nothing, and says so once through `ctx.ui.notify`. An object published before
202
+ `registerToolDesign` existed keeps the section and says `resolver absent`; one published before
203
+ `registerCardStyle` says `card style absent`: the members are optional, and a surface the bridge
204
+ never promised is never warned about. The bridge reports the version skew
205
+ once per process.
206
+
207
+ ## Install
208
+
209
+ The package is `0.3.0`. Load it from source as a Pi extension package alongside gentle-shell
210
+ and Pi discovers `src/extension.ts` through the `pi` manifest. The shipped files are the
211
+ TypeScript sources: Pi loads them through jiti, so there is no build step and no `dist/` to
212
+ publish.
213
+
214
+ For a plugin that imports the API, `exports` maps the package root to `src/index.ts`:
215
+
216
+ ```ts
217
+ import {
218
+ BRIDGE_READY_EVENT,
219
+ bridgeApi,
220
+ createStore,
221
+ isBridgeApi,
222
+ onBridgeApi,
223
+ registerCardStyle,
224
+ registerRows,
225
+ registerSection,
226
+ registerTransform,
227
+ STORE_SCHEMA_VERSION,
228
+ VERSION,
229
+ } from "gentle-shell-bridge";
230
+ ```
231
+
232
+ Importing is optional: a plugin can reach the bridge through the runtime handshake below without
233
+ resolving the package at all.
234
+
235
+ `engines.node` is `>=22.18.0` because the test suite runs `.ts` files directly through
236
+ Node's native type stripping, with no transpile step.
237
+
238
+ ## Quick start: a sidebar section
239
+
240
+ The two quick starts below show the call itself. Make it from your extension's `session_start`
241
+ handler — or, if your plugin may load before this package, through `onBridgeApi(handler, pi.events)`,
242
+ which answers in both orders.
243
+
244
+ The second surface needs no `Sections` row, no setting name and no store of its own — one call
245
+ adds the card to the sidebar and its show/hide toggle to `/gentle:customize` → `Sections`.
246
+
247
+ ```ts
248
+ import { registerSection } from "gentle-shell-bridge";
249
+
250
+ const dispose = registerSection({
251
+ id: "loon.music", // unique and stable: ordering, replacement, the toggle; internal
252
+ title: "MUSIC", // card heading, framed by the host like AGENTS / STATS
253
+ rowLabel: "music", // optional: the text the Sections row reads (derived from title)
254
+ meta: () => "1 playing", // optional right-aligned count; read per frame, so it is live
255
+ lines: () => [ // per frame, live
256
+ "track Blue in Green", // a plain string is drawn byte for byte, exactly as before
257
+ { // a structured row is drawn like a native one
258
+ glyph: "▸",
259
+ label: "album",
260
+ meta: "Kind of Blue",
261
+ tone: "muted",
262
+ onClick: () => play(), // only a row with onClick is clickable
263
+ },
264
+ ],
265
+ defaultVisible: true,
266
+ order: 10, // optional; unordered sections follow, in registration order
267
+ });
268
+
269
+ // dispose() removes the card and its toggle; other sections are untouched.
270
+ ```
271
+
272
+ The card behaves like a host card. The HEADER is the host's own header — `▾ MUSIC`, the right-aligned
273
+ `meta`, the hover accent — and clicking it folds the section (a folded body is not built at all, and
274
+ the fold is remembered in the bridge's own store). A row with `onClick` is clickable and highlights
275
+ under the pointer; a row without it is inert and never highlights. `meta` is read at render time and
276
+ folded into the rail's digest, so a value that changes is repainted with no re-registration.
277
+
278
+ The `tone` of a structured row names one of the host's own roles — `text`, `muted`, `dim`, `accent`,
279
+ `success`, `error`, `warning` — never a colour, and anything else is rejected and reported.
280
+
281
+ Where the header and rows are DRAWN is reported, not assumed: the host's own
282
+ `renderPanelHeader`/`renderPanelRow` live in a module this package cannot import, so the bridge looks
283
+ for them on every object it is handed, uses them when it finds them, and otherwise draws with a
284
+ byte-identical port. `/bridge-status` says which it used and what remains unreachable
285
+ (`section frame: host` or `section frame: fallback` plus a `section frame gap` line).
286
+
287
+ The row is a **label**, never the id and never a prefix. The host's own `Sections` rows read
288
+ `changes`, `stats`, `usageCost`; a plugin's section reads the same way:
289
+
290
+ ```text
291
+ stats ● shown ○ hidden ← the host's own row
292
+ music ● shown ○ hidden ← this plugin's section
293
+ ```
294
+
295
+ `id` stays internal: it is the replacement, disposer and stored-visibility key, and it never
296
+ appears in a row. With no `rowLabel`, the bridge derives one from `title` — lowercase words joined
297
+ by spaces, dots and punctuation dropped, at most three words (`"CARD BASH"` → `"card bash"`,
298
+ `"MUSIC PLAYER NOW PLAYING"` → `"music player now"`). Two sections whose labels would read the same
299
+ are told apart deterministically (`music`, `music 2`, …) and `/bridge-status` says which one moved;
300
+ the raw id is never the suffix. An explicit `rowLabel` obeys the same row-name shape described under
301
+ "Row names" below — a name that breaks it is rejected and reported, never rewritten.
302
+
303
+ What the bridge does with those fields, and what it will not do: the card is drawn by the
304
+ host's own frame code, a plain string row is clipped to the rail width, and the body is capped at 8
305
+ rows (with a `… +N more` marker). A row is measured the way the host measures it — `visibleWidth`:
306
+ SGR ignored, a TAB is three columns, and a grapheme cluster is charged as a whole, so an emoji is two
307
+ columns and a CJK glyph two. A contributed row is never wider than the width the host handed the
308
+ part, padding included, so it cannot make the host drop the rail; `/bridge-status` reports that
309
+ width and the widest row the bridge produced. A hidden section is not built at
310
+ all (its `lines()`/`meta()` are not called), nor is a collapsed one, and a throwing section loses
311
+ only its own body and is reported by `/bridge-status`.
312
+ Sections always render **after** the native cards — interleaving is impossible because the
313
+ host builds its status sections in a closure. Visibility and the fold live in
314
+ `~/.pi/gentle-ai/gentle-shell-bridge/bridge-sections.json` as
315
+ `{ "schemaVersion": 1, "values": { "sections": { "loon.music": true }, "collapsed": { "loon.music": true } } }`,
316
+ deliberately not in the host's strictly validated `visual-customization.json`.
317
+
318
+ ## Quick start: a card style
319
+
320
+ A plugin can add a third card style to gentle-shell's cards, on the host's own `Card style` line,
321
+ without touching the host. The bridge owns detection; the style owns drawing. A style is one global
322
+ choice, so it paints every card surface the host frames — the **conversation** and the fullscreen
323
+ **rail** (host sections like `CHANGES` and contributed sections alike) — and `native` restores the
324
+ host's own frames on both. The rail is covered by the same `transformCardLines` the conversation
325
+ uses, so a plugin registers nothing extra for it.
326
+
327
+ ```ts
328
+ import { registerCardStyle } from "gentle-shell-bridge";
329
+
330
+ const dispose: () => void = registerCardStyle({
331
+ id: "loon.cute", // unique; the stored value and the replacement key — internal, never a row
332
+ label: "cute", // the line's value: Card style ○ neon ● float ○ cute
333
+ rowLabel: "cute", // optional: what this style's row reads (derived from label when absent)
334
+ frame: {
335
+ top: (ctx) => `╭─ ${ctx.title ?? ""} ${"─".repeat(Math.max(0, ctx.width - 6))}╮`,
336
+ line: (ctx) => `│ ${ctx.text.padEnd(Math.max(0, ctx.width - 4))} │`,
337
+ bottom: (ctx) => `╰${"─".repeat(Math.max(0, ctx.width - 2))}╯`,
338
+ },
339
+ preview: () => ({ title: "Cute", sample: "╭─ read ─╮ │ config.ts │ ╰────────╯" }),
340
+ });
341
+
342
+ // The user picks `cute` on the host's own Card style line; pressing neon/float returns to the
343
+ // host's rendering. dispose() removes the value and the chrome.
344
+ ```
345
+
346
+ Rules the row cannot break: `id` and `label` are unique among the line's values (the host's
347
+ `neon`/`float` and the bridge's `native` are taken), the visible text is a label (`label` on the
348
+ line, `rowLabel` — or `label` made readable — for the row), the id never appears in a row, and the
349
+ style only ever receives text and a width — it parses nothing. Full contract: [docs/contract.md](docs/contract.md) §12.
350
+
351
+ ## Quick start: a tool design
352
+
353
+ A design decides how ONE tool's card is drawn, composed with the host's own card. You register it for
354
+ a tool NAME; the bridge owns Pi's single `registerToolRenderer` resolver and hands your `apply` the
355
+ rows the host itself drew:
356
+
357
+ ```ts
358
+ import { registerToolDesign } from "gentle-shell-bridge";
359
+
360
+ const dispose: () => void = registerToolDesign({
361
+ tool: "bash", // a tool name, present or future: no bash-specific branch anywhere
362
+ id: "loon.bash-heading", // unique per tool; the stored value and the replacement key
363
+ label: "bash heading", // the value on the Cards line: Card bash ○ native ○ bash heading
364
+ name: "Loon kit", // optional: this plugin's INTEGRATIONS row
365
+ preview: () => ({ title: "Cards · bash heading", sample: "╭─ bash npm test ─╮" }),
366
+ apply(lines, ctx) { // the HOST's rows in, the rows to draw out
367
+ // ctx is { width, tool, theme, part }; lines already carry the host's frame, ANSI and padding.
368
+ // `part` is "call" or "result", so a heading is only looked for on the call.
369
+ // Change only the mark the design is for, and keep every other row byte-identical:
370
+ if (ctx.part === "result") return [...lines];
371
+ return lines.map((line) => rewriteTheMarkOnly(line));
372
+ },
373
+ });
374
+
375
+ // The user picks `bash heading` on its Cards line; `native` (the host's own card) stays the default,
376
+ // and the choice repaints cards already drawn. dispose() removes the value.
377
+ ```
378
+
379
+ What the bridge guarantees:
380
+
381
+ - **the frame is the host's**, so the card style is preserved by construction — `neon` stays neon,
382
+ `float` stays float, and every byte you do not change is returned as drawn;
383
+ - the design's `Cards` line appears with no extra wiring, one line per tool, named `Card <tool>`
384
+ (`Card bash`, `Card lsp navigation`, `Card mem save` — `Card` plus at most two tool words, the
385
+ TAIL kept when a long name has to be clamped), whose values are `native` (the host's card, the
386
+ default) and every design registered for that tool;
387
+ - the selection is persisted per tool in the bridge's own store, and changing it repaints the cards
388
+ already on screen, not only the next one;
389
+ - a design declared for a tool with no card yet applies as soon as Pi builds one, because Pi resolves
390
+ renderers when the card is constructed;
391
+ - a design that throws loses only its own card, and the host's rows are drawn instead.
392
+
393
+ Full contract: [docs/contract.md](docs/contract.md) §14.
394
+
395
+ ## Quick start: a transcript transform
396
+
397
+ A tool renderer resolves once, when a row is built. A transform runs on every frame the conversation
398
+ component renders, which is the only way a rendering choice can reach rows that were already drawn.
399
+
400
+ ```ts
401
+ // The context a transform receives: the host's width, and the optional telemetry channel.
402
+ interface TransformContext {
403
+ width: number;
404
+ // Optional because an older published bridge has none: gate with typeof ctx.report === "function".
405
+ report?(counts: {
406
+ candidates: number; // rows examined as a possible card head
407
+ recognised: number; // candidates the grammar accepted
408
+ rewritten: number; // cards actually rewritten this frame
409
+ shapes?: Record<string, number>; // which shape matched, by name
410
+ }): void;
411
+ }
412
+
413
+ declare const registerTransform: (transform: {
414
+ id: string;
415
+ order?: number;
416
+ transform(lines: readonly string[], ctx: TransformContext): readonly string[];
417
+ }) => () => void;
418
+
419
+ const dispose = registerTransform({
420
+ id: "loon.gutter", // unique: the ordering/replacement key and the /bridge-status name
421
+ order: 10, // optional: ordered transforms run first, ascending
422
+ transform: (lines, ctx) => {
423
+ const kept = lines.map((line) => line.slice(0, ctx.width));
424
+ // Counts only — never a line of the conversation. This is what tells "the grammar matched
425
+ // nothing" apart from "the wrapper never ran" when a setting seems to do nothing.
426
+ ctx.report?.({ candidates: lines.length, recognised: kept.length, rewritten: lines.length });
427
+ return kept;
428
+ },
429
+ });
430
+
431
+ // The user sees the new rendering on the NEXT FRAME, for every row — including cards drawn before
432
+ // the choice was made. dispose() removes this transform; other plugins' transforms keep running.
433
+ ```
434
+
435
+ The pipeline is what the bridge's existing conversation wrapper runs after the card-style repaint:
436
+ `lines` is the previous transform's output, and the first transform sees the card-style repaint of
437
+ the host's own rows. What the bridge guarantees, and what the contract is:
438
+
439
+ - what a transform returns is **clipped to `ctx.width`** (the width the host rendered at), so a
440
+ transform can never make the frame wider than the terminal. The measure is the host's (`TAB` three
441
+ columns, a grapheme cluster as a unit, an OSC-8 hyperlink contributes its text and not its URL), and
442
+ a row that already fits is returned **byte-identical** — a transform that did not change a row cannot
443
+ make the bridge cut it. A row that must be cut is cut between clusters, with an open hyperlink closed
444
+ and an active SGR reset, so nothing leaks into the rows after it;
445
+ - a transform that **throws**, or returns anything that is not an array of strings, loses only its
446
+ own contribution for that frame; the lines in force are kept and the remaining transforms still run;
447
+ - a transform that returns its input **unchanged** is recorded as `skipped` and the frame keeps its
448
+ identity — with no transform registered, the wrapper returns the host's own array instance.
449
+
450
+ `/bridge-status` lists the pipeline in order with the last frame's outcome per transform, then the
451
+ counts that same frame recorded — lines received, and what the transform reported:
452
+
453
+ ```text
454
+ transforms: 1 registered
455
+ - loon.bash-cards (order 10): applied, 13 lines, 4 candidates, 1 recognised, 1 rewritten, shapes: float 1
456
+ ```
457
+
458
+ Those counts are the instrument, not decoration: `skipped` with `0 candidates` means the grammar
459
+ found nothing card-shaped, candidates with `0 recognised` means it found the shape and rejected it,
460
+ and recognised with `0 rewritten` means a setting decided the output. A shape name is the transform's
461
+ own word for a frame it knows — a plugin that reads `float` and `neon` reports those names — so the
462
+ report names which grammar matched. They are counts only — no line
463
+ of the conversation is stored or displayed, a shape name must be a short lowercase name (anything
464
+ else is dropped, never rewritten), and reporting cannot throw into the frame. Full contract:
465
+ [docs/contract.md](docs/contract.md) §13.
466
+
467
+ A tool design reports its own block, because "the design is selected but the card did not change" has
468
+ exactly two useful answers: which design resolves now, and whether the one resolver has mounted a
469
+ card at all.
470
+
471
+ ```text
472
+ tool designs: 1 tools with designs
473
+ - bash: bash-card.bash-heading, wrapper mounted on 2 cards
474
+ bash-card.bash-heading "bash heading" — heads the card with bash instead of the host's $ mark
475
+ tool design resolver: registered (1 registered)
476
+ ```
477
+
478
+ ## Reaching the bridge without importing it
479
+
480
+ A plugin and the bridge are two independent projects, so a path to one checkout is not a contract.
481
+ They meet on `globalThis`, behind one stable symbol:
482
+
483
+ - `Symbol.for("gentle-shell-bridge/api/v1")` holds
484
+ `{ schema: "gentle-shell-bridge/api", version: 1, VERSION, registerRows, registerSection, registerCardStyle, registerTransform, registerToolDesign, requestRepaint, createStore }`.
485
+ It is frozen, first-wins (a valid object already there is kept, not replaced) and never reset.
486
+
487
+ **Register at `session_start` if the package loaded first, otherwise when it announces itself.**
488
+ The symbol is the authority; the announcement is the timing. Pi does not guarantee that this
489
+ package loaded before your plugin: global extensions (`~/.pi/agent/extensions`) load, and receive
490
+ `session_start`, before a configured package's module publishes — observed in a real TUI, where the
491
+ worked example said "no compatible bridge" while a bridge was about to appear. So a plugin loaded
492
+ early listens for one event:
493
+
494
+ ```ts
495
+ pi.events?.on("gentle-shell-bridge:ready", ({ schema, version, VERSION }) => {
496
+ // the object is published now: read the symbol again and register
497
+ });
498
+ ```
499
+
500
+ The bridge emits it **once per process**, after a successful publish, and never when the slot holds
501
+ an incompatible payload. It carries identity only — `{ schema, version, VERSION }` — because it is a
502
+ notification, not a registry: the listener reads the symbol and applies the same gate. A Pi without
503
+ `pi.events` emits nothing, and the symbol still works for anyone who reads it.
504
+
505
+ Inline the gate (the example does), or import the package and let `onBridgeApi` handle both forms —
506
+ handler called immediately when the object is already there, subscribed when it is not, detached
507
+ after one delivery:
508
+
509
+ ```ts
510
+ const KEY = Symbol.for("gentle-shell-bridge/api/v1");
511
+ const isApi = (v: any) => typeof v === "object" && v !== null && v.schema === "gentle-shell-bridge/api" &&
512
+ v.version === 1 && typeof v.registerRows === "function" &&
513
+ typeof v.registerSection === "function" && typeof v.createStore === "function";
514
+
515
+ const register = (): boolean => {
516
+ const api = (globalThis as any)[KEY];
517
+ if (!isApi(api)) return false;
518
+ api.registerSection({ id: "loon.music", title: "MUSIC", lines: () => ["…"] });
519
+ return true;
520
+ };
521
+
522
+ if (!register()) pi.events?.on("gentle-shell-bridge:ready", register);
523
+
524
+ pi.on("session_start", (_event, ctx) => {
525
+ if (!register()) {
526
+ ctx.ui?.notify("my-plugin: waiting for gentle-shell-bridge to announce itself", "warning");
527
+ }
528
+ });
529
+ ```
530
+
531
+ A plugin that imports the package gets both forms in one call — the handler runs immediately when
532
+ the object is published and when the announcement arrives otherwise, and the subscription detaches
533
+ itself after one delivery:
534
+
535
+ ```ts
536
+ import { onBridgeApi } from "gentle-shell-bridge";
537
+
538
+ const dispose = onBridgeApi((api) => {
539
+ api.registerRows({ id: "loon.banner-source", category: "Cards", rows: () => [] });
540
+ }, pi.events); // works in both load orders; `bridgeApi()` alone does not wait
541
+ ```
542
+
543
+ Rules worth knowing: an object that fails the gate (wrong schema, `version !== 1`, a missing member)
544
+ is **absent**, never an error — `bridgeApi()` returns `undefined`, one warning is emitted per
545
+ process, and the slot is left exactly as it was. A breaking change takes a new `v2` symbol key
546
+ rather than mutating `v1`. An older bridge publishes nothing at this key, so a newer plugin sees
547
+ "absent" instead of being deferred silently; that compatibility cliff is intentional. The full rule
548
+ and complete snippets are in [docs/contract.md](docs/contract.md) §9, with the surfaces documented in
549
+ §9 (rows, sections, store), §12 (card styles) and §13 (transcript transforms).
550
+
551
+ ## Quick start: rows
552
+
553
+ ```ts
554
+ import { createStore, registerRows } from "gentle-shell-bridge";
555
+
556
+ const store = createStore("loon"); // ~/.pi/gentle-ai/gentle-shell-bridge/loon.json
557
+ const read = () => store.get("bannerSource", "activity");
558
+
559
+ const toggle = (value: string) => ({
560
+ category: "Banner" as const,
561
+ label: () => "Loon banner source",
562
+ choice: { setting: "Loon banner source", value, current: () => read() === value },
563
+ action: () => store.set("bannerSource", value), // persist first; the menu re-renders
564
+ });
565
+
566
+ const dispose = registerRows({
567
+ id: "loon.banner-source",
568
+ category: "Banner",
569
+ rows: () => [toggle("activity"), toggle("output")], // consecutive, same setting → one line
570
+ });
571
+
572
+ // dispose() removes every row this provider contributed; other providers are untouched.
573
+ ```
574
+
575
+ ### Row names
576
+
577
+ `choice.setting` (the line's name) and `label` (the row's text) are read by a human, so both obey one
578
+ shape: **one to three plain words**, title case like the host's own `Card style` / `Card content`,
579
+ and **none of** `→ ← ↑ ↓ : . | / \ [ ] ( ) { }`. No trailing qualifier — the category says where the
580
+ row lives and the values say what the choices are:
581
+
582
+ ```text
583
+ Bash design OK a row name, in the host's own vocabulary
584
+ Bash heading → Bash rendering BAD an arrow and a repeated qualifier: reads as a breadcrumb
585
+ Bash heading: rendering BAD a colon
586
+ Section: bash-card.status BAD a prefix plus a dotted internal id
587
+ ```
588
+
589
+ The bridge enforces this rather than trusting authors, and it never rewrites a name it refused:
590
+
591
+ - a provider row whose line name or label breaks the shape is dropped from the menu build, so it
592
+ reaches neither the host's rows nor the rows `/bridge-status` compares;
593
+ - a section or a card style whose `rowLabel` breaks it is not registered at all;
594
+ - the offending string and the rule it broke are recorded verbatim and listed by `/bridge-status`
595
+ under `row rejections`.
596
+
597
+ ## Supported row kinds
598
+
599
+ Every field below is already understood by gentle-shell's own renderer; the bridge carries rows
600
+ as authored, with one deliberate exception: it sets `choice.applyOnMove: false` on every row it
601
+ contributes, so a toggle is navigated and confirmed with Enter instead of switching as the cursor
602
+ passes over it (the host's own rows keep their own field, so `Card style · neon/float` still
603
+ applies on ←/→). While a bridge row is focused, the host's footer reads exactly
604
+ `←/→ choose · Enter apply` instead of `←/→ change`, and a move writes nothing: no store update, no
605
+ persisted value, no repaint. Enter applies once.
606
+
607
+ | Kind | Shape | Renders as |
608
+ | --- | --- | --- |
609
+ | Toggle | two or more consecutive rows sharing `choice.setting` | one line `Name ● current ○ other`; ←/→ moves the `›` cursor and the footer reads `←/→ choose · Enter apply`, Enter applies (the bridge always sets `applyOnMove: false`) |
610
+ | Action | a row without `choice` | one line; Enter/Space runs `action(inline)` |
611
+ | Gated | `visible()` returns `false` | row hidden; a throw fails closed and hides it |
612
+ | Preview | `preview()` returns `{ title, sample }` | read-only preview for the focused row |
613
+ | Inline editor | `action` calling `inline.input()`, `inline.confirm()`, `inline.capture()` | field, confirmation or key capture drawn inside the card |
614
+ | Hint | `keyhint` | per-row footer hint instead of the category default |
615
+
616
+ `label` may be a string or a function, and is evaluated when the menu is built, so it can
617
+ read live state. Full details, conflict policy and failure isolation:
618
+ [docs/contract.md](docs/contract.md).
619
+
620
+ ## Diagnostics
621
+
622
+ The bridge registers one command, `/bridge-status`. It reports the registered providers (id
623
+ and category, in subscription order), the active backend (`hook` or `capture`), the last
624
+ capture outcome with the injected row count, the last provider failures, store problems and
625
+ duplicate `category` + `setting` pairs across providers, and any row name the shape rule rejected —
626
+ the surface, the registration id, the offending string verbatim and the rule it broke, under
627
+ `row rejections` — so a missing row is an answer, not a silent no-op. Duplicates are computed
628
+ from the rows the last menu build actually received, so before any build the report says
629
+ `duplicate settings: no build yet` instead of claiming there are none. Diagnostics never run a
630
+ provider's `rows()`.
631
+
632
+ The first answer is whether the required host package is there: `gentle-pi: present`, or
633
+ `gentle-pi: missing — <reason>` followed by one `bridge: disabled` line, because this bridge needs
634
+ `gentle-pi` (it feeds `/gentle:customize`) and arms nothing without it — no rows, sections, card
635
+ styles, tool designs or wrappers, and no per-surface block claiming work it is not doing.
636
+
637
+ It also answers the sidebar question: a `sections` table (id, title, the `row` label the menu
638
+ reads plus a collision note when two labels wanted the same text, persisted visibility — and
639
+ `collapsed` while the card is folded — and where the position comes from), the most recent frame's
640
+ `section failures` (phase `lines`, `meta` or `onClick`), and a `sidebar` line —
641
+ `intercepted (rail wrapped)`, `intercepted (rail pending)`, `no-sidebar-state`, or
642
+ `not armed` with the reason the wiring could not acquire a `tui`. Three more lines say how the card
643
+ is drawn and how the pointer reaches it: `section frame: host` or `fallback` with what was looked
644
+ for and whether a theme had arrived, `section interaction:` with the pointer path and the hover
645
+ claim, and — in the fallback case — a `section frame gap:` line naming what cannot be reached. Those
646
+ blocks appear only when they have something to say, so a row-only install keeps the historical
647
+ output. Diagnostics never run a section's `lines()`/`meta()` either; the rail's digest does, during a
648
+ frame. The hook backend is reported as
649
+ `hook` only when this process pushed its fan-out provider into a host registry. Ids stay in this
650
+ report on purpose: it is for the plugin author diagnosing a registration, not for the user picking
651
+ a toggle.
652
+
653
+ For card styles it reports the line mode (`extended` when the host's own line was found,
654
+ `separate` when the fallback line was used), the registered styles in order, the live selection
655
+ (including a disposed one, as `missing`), what the last frame did (`transformed N cards`,
656
+ `pass-through (native | missing | no card)`, or `failed` with the style and message), the per-style
657
+ failures, whether the conversation component was wrapped, and the reminder that a downstream
658
+ post-processor can override the frame. Per surface it also says whether the style reached it and what
659
+ its last frame did there (`card style surface rail: reached (rail wrapped), last frame transformed 3
660
+ cards`, or `not reached (no-layout-root)`), and it reports the last repaint a bridge-owned write
661
+ requested — `last repaint: rail for section "loon.music" → hidden (repainted)` — including the reason
662
+ when no frame could be asked for. A visibility toggle and a card-style selection repaint
663
+ synchronously, before the action returns; nothing polls.
664
+
665
+ For transcript transforms it reports the registered pipeline in order — `order 10` or `registration
666
+ order` — and what the last frame did with each one: `applied`, `skipped`, `failed` with the message,
667
+ or `not run` for a transform registered after that frame. The block appears only when a transform is
668
+ registered, so a session that never used the pipeline keeps the historical report byte for byte.
669
+
670
+ The bridge also sets **one INTEGRATIONS row per plugin** in Pi's footer, through
671
+ `ctx.ui.setStatus(key, text)`, and reports it as a per-plugin list: the name and whether it was
672
+ `derived from package "@acme/loon-kit"` or an `explicit name`, the registrations `accepted` and
673
+ `rejected`, and whether the row is set — or `no line set` with the reason. The header says when the
674
+ host's `setStatus` is unavailable at all. The rules that line obeys:
675
+
676
+ - **The row text is opaque.** The host renders the whole string as one row, so everything the row
677
+ says is inside it: `Loon kit · ready`. Its `sanitizeStatus` strips ANSI and collapses whitespace,
678
+ so **ANSI is not available** to a plugin, and any glyph must be part of the text
679
+ (`🧠 gentle-pi · ready` is the host's own fixture).
680
+ - **Colour belongs to the host.** Every entry is painted with one shared role; a plugin chooses a
681
+ glyph, never a colour.
682
+ - **One plugin means one line.** Ten rows, three sections, a card style, a transform and a tool
683
+ design still paint one row; later changes repaint the same key in place, and `session_shutdown`
684
+ clears it with `undefined`.
685
+ - **The name is derived, or given once.** The bridge resolves the module that registered to its
686
+ owning `package.json` and derives one to three plain words (`@acme/loon-kit` → `Loon kit`); any
687
+ registration may pass `name`, and the first usable declaration fixes it for the plugin.
688
+ - **Identity is the plugin; the name is sticky.** A plugin is its owning package root (or the module
689
+ when no package owns it), resolved once and keyed from that identity — never from the display
690
+ name, so a rename repaints the same row and never adds one. A single copied file with no owning
691
+ package gets **no row** (fail closed, the reason is in `/bridge-status`) unless it passes that
692
+ `name`. The Pi installation is never a plugin identity: a module owned by the package that
693
+ provides `pi` fails closed the same way, so its shared extensions directory cannot add a
694
+ `Pi coding agent` row. A later registration that declares a different name never renames the
695
+ row — the first name stays and `/bridge-status` reports the conflict. A rejected name is never
696
+ shown.
697
+ - **`ready` is derived, never promised.** One rejected registration — an invalid id, a row label that
698
+ broke the shape rule — makes the same key read `degraded`, and the report names the rejected
699
+ items.
700
+
701
+ ## Limits
702
+
703
+ - **A new category is a page, with one reserved name.** The host derives its pages from the rows
704
+ (`lib/visual-customize-view.ts:387`) once, in the view's constructor, while the capture backend
705
+ used to inject rows after the factory returned. The bridge now appends the providers' rows from
706
+ inside the host's own `map`, provisionally and only on an array proven to be the menu's rows, and
707
+ takes them back out if the late decision picks another array, the build is ambiguous, the factory
708
+ returns a promise or it throws; so a brand-new category appears as a page after the host's own.
709
+ `Keymaps` is reserved (its rows capture keys and claim a footer): a provider or a row naming it,
710
+ in any case or with surrounding spaces, is refused and reported. `Keymap`, `Key maps`, `Cards`,
711
+ `Profiles` and `Reset` are ordinary names.
712
+ - **No upstream hook exists.** The bridge depends on gentle-shell internals; the day
713
+ gentle-shell changes its menu construction, the fix is in one place, but it is still a
714
+ fix against internals rather than a supported extension point.
715
+ - **The capture backend patches `Array.prototype.map`** for the duration of one
716
+ synchronous factory call. It is restored in a `finally` block, it never survives the
717
+ call, and it is only installed inside the customize-menu path.
718
+ - **The capture is heuristic.** It recognises the row array by shape; a menu that builds
719
+ its rows differently produces no capture. That case is reported by `/bridge-status` instead of
720
+ passing silently.
721
+ - **Card styles are a repaint, not a host feature.** The bridge rewrites the `float` frames
722
+ gentle-shell already drew, so it depends on that grammar, and a downstream post-processor that
723
+ re-frames the conversation and strips box chrome can override the result — the bridge
724
+ says so in `/bridge-status` instead of pretending otherwise. Detection is proven against a fake
725
+ conversation built from real `shell-card.ts` output, never in a running TUI, and a card with a
726
+ blank body row is detected as ending at that row.
727
+ - **A transform is a per-frame pass, not a host feature either.** It runs inside the wrapper the
728
+ bridge already installs on the conversation component, so it depends on the same Pi tree the card
729
+ styles do, and a downstream post-processor can still override the frame. A transform sees lines,
730
+ not tool metadata: a plugin that needs the call's arguments or the result object still has to use
731
+ `pi.registerToolRenderer` for rows that are yet to be built. A throwing transform is isolated per
732
+ frame; a slow one runs on every frame, which the bridge cannot bound.
733
+ - **Tests cover a fake host, not the real TUI.** 673 tests run under `node --test`; 26 of
734
+ them are end-to-end through the public API and the extension entry, against a fake host
735
+ that models `gentle-shell.ts:3550-3562` and a fake sidebar that models
736
+ `lib/shell-sidebar.ts` and the layout's card frame. The capture technique is verified in the
737
+ real TUI only by the reference probe, and the hook backend has never run against a real host.
738
+ - **The sidebar has never been seen in a running TUI.** The rail interception, the section
739
+ cards and the automatic `Sections` rows are proven against the fake rail. In particular,
740
+ whether the widget Pi hands the `tui` through leaves the editor area visually untouched is
741
+ **unconfirmed** — the component renders no rows, which is the minimal guess.
742
+ - **Sections render after the native cards, never between them.** The host's status sections
743
+ are built in a closure, so interleaving is not offered.
744
+ - **The sidebar needs `ctx.ui.setWidget`.** Pi exposes no `tui` getter; without the widget
745
+ factory the rail cannot be reached, and `/bridge-status` reports `sidebar: not armed` with
746
+ the reason while the `Sections` toggle keeps working.