gentle-shell-bridge 0.3.1 → 0.3.2

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,746 +1,115 @@
1
1
  # gentle-shell-bridge
2
2
 
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.
3
+ One interception layer between gentle-shell and the plugins that want a line in its menus, a card
4
+ in its rail, or a frame in the transcript.
8
5
 
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**.
6
+ ## What It Does
21
7
 
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
8
+ gentle-shell builds its `/gentle:customize` menu, its sidebar, and its card frames inside closures
9
+ that no plugin can reach. A plugin that wants a settings row therefore has to wrap the host, patch
10
+ it, and hopefully survive the next release. Two plugins doing that do not compose: the inner
11
+ wrapper wins the capture, the outer one gets nothing, and the menu silently loses a plugin's rows.
26
12
 
27
- ## Worked consumer
13
+ This package owns that interception exactly once. A plugin registers what it contributes — settings
14
+ rows, a sidebar section, a card style, a transcript transform, a card design for one tool, a keymap
15
+ action, palette items — and the bridge turns those registrations into what the host draws. The
16
+ plugin keeps its own state; the bridge keeps the only wrapper.
28
17
 
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.
18
+ What changes for a user: every installed plugin's settings appear in the one menu they already
19
+ open, and a plugin whose surface cannot be reached says why in `/bridge-status` instead of
20
+ disappearing.
35
21
 
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:
22
+ ## Quick Start
38
23
 
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.
24
+ Add the published package next to gentle-shell and restart Pi:
187
25
 
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";
26
+ ```bash
27
+ pi install npm:gentle-shell-bridge
230
28
  ```
231
29
 
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.
30
+ Pi reads the `pi` manifest and loads `src/extension.ts`. The shipped artifact is the TypeScript
31
+ source — Pi loads it through jiti, so there is no build step and no `dist/`.
606
32
 
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 |
33
+ Two surfaces need a host that reads them: **keymaps** and **command palette**. They land only in a
34
+ gentle-shell that includes the consumer side; the bridge publishes the contributions either way, and
35
+ `resolveKeymap` reports the missing host instead of failing.
615
36
 
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).
37
+ Working from a checkout instead? Point Pi's package list at the directory; nothing else changes.
619
38
 
620
- ## Diagnostics
39
+ ## Core Workflow
621
40
 
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()`.
41
+ A plugin reaches the bridge from its own `session_start`, through the published object or through
42
+ the announcement event if the bridge loaded later. It then registers what it contributes and keeps
43
+ the disposer, so a `/reload` replaces its registration instead of stacking a second one.
631
44
 
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.
45
+ A registration is evaluated when a menu is built, not when it is made, so rows always reflect live
46
+ state. Anything a registration throws is isolated to that registration and reported: one broken
47
+ plugin costs its own rows, never the menu.
636
48
 
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.
49
+ Every call, its arguments and its guarantees live in the contract, linked below. The two newest
50
+ surfaces — a plugin's keymapped action, and its items in the command palette — are the ones worth
51
+ reading first, because they are what a plugin could not do at all before.
652
52
 
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.
53
+ ## Key Features You Should Know About
664
54
 
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.
55
+ - **Settings rows** for the host's categories, or a page of your own: toggles, button rows, and the
56
+ host's own `●`/`○` marker, arrow handling and footer for a focused row — `←/→ choose · Enter apply`
57
+ — so a plugin never draws chrome of its own.
58
+ - **Sidebar sections** that join the rail after the native cards, each contributing its own
59
+ visibility toggle automatically.
60
+ - **Card styles** and **transcript transforms**: the bridge rewrites frames the host already drew,
61
+ so the rest of the visual system stays the host's.
62
+ - **Card designs per tool name**, behind one `pi.registerToolRenderer` resolver for the whole
63
+ process, so two plugins cannot stack wrappers on the same tool.
64
+ - **Keymaps.** A plugin's action appears on the host's Keymaps page, is reassigned there with the
65
+ native gestures, and takes part in the same reserved-key and duplicate-key checks. The plugin
66
+ binds the shortcut itself and asks the shell which key to bind, so the row and the binding cannot
67
+ disagree. Like every native keymap, a change applies after `/reload`.
68
+ - **Command palette items**, into a section the host already has or one of the plugin's own.
69
+ - **A namespaced store** for whatever a plugin wants to persist, with the bridge's own files kept
70
+ apart from the host's.
669
71
 
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:
72
+ ## Documentation
675
73
 
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.
74
+ | Your task | Start here |
75
+ | --- | --- |
76
+ | Call any `register*` correctly, and know what each one guarantees | [docs/contract.md](docs/contract.md) |
77
+ | Understand how the bridge reaches a sealed host, and why it is done this way | [docs/architecture.md](docs/architecture.md) |
78
+ | Know what the bridge persists, and where | [docs/state.md](docs/state.md) |
79
+ | See a real consumer, end to end | [examples/bash-card.ts](examples/bash-card.ts) |
80
+ | Read the requirements and the reason each one exists | [PRD.md](PRD.md) |
700
81
 
701
82
  ## Limits
702
83
 
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.
84
+ - **No upstream hook exists.** The bridge depends on gentle-shell internals. The day the host
85
+ changes how it builds its menus, the fix belongs here in one place — but it is still a fix
86
+ against internals, not a supported extension point. If Pi or gentle-shell ever ships a
87
+ row-provider hook, this package should shrink to a thin adapter over it.
88
+ - **The capture is heuristic.** Rows are recognised by shape, inside one synchronous factory call,
89
+ and the `Array.prototype.map` patch is restored in a `finally`. A menu built differently produces
90
+ no capture, and `/bridge-status` reports that instead of passing silently.
91
+ - **`Keymaps` stays reserved for row providers.** The host's keymap page captures keys and claims a
92
+ footer, so rows there would be advertised as assignable and could never honour it. The supported
93
+ path is the explicit keymap registration, which the host owns end to end.
94
+ - **Card styles and transforms are repaints, not host features.** They depend on the host's frame
95
+ grammar, and a downstream post-processor that re-frames the conversation can still override the
96
+ result.
97
+ - **Tests run against a fake host.** The suite covers the public API and the extension entry
98
+ against a fake menu, a fake rail and a fake transcript that model the host's shapes; the real TUI
99
+ is where a change is finally seen.
100
+
101
+ ## Releasing
102
+
103
+ Versions are cut from a tag: preflight checks that the tag equals the manifest version, runs the
104
+ tests and the typecheck, and prints the exact tarball; publication goes through npm Trusted
105
+ Publishing with no token anywhere; verification reads the registry back and fails if `latest` is
106
+ not the version that was tagged. A published version is immutable, so a mistake is fixed forward
107
+ with the next version.
108
+
109
+ ## Next Steps
110
+
111
+ - Read the contract's first section, then register a row against the fake host in a scratch
112
+ extension before touching a real one.
113
+ - If your plugin ships a keymap or palette items, check `/bridge-status` on a host without support
114
+ first: it tells you exactly which surface is missing, and that is the state you want to degrade
115
+ in gracefully.
package/docs/contract.md CHANGED
@@ -7,7 +7,7 @@ What a plugin can rely on. The mechanism behind it is in
7
7
 
8
8
  ```ts
9
9
  const dispose: () => void = registerRows(provider);
10
- ```
10
+ ```text
11
11
 
12
12
  Registers a row provider with the process-global bridge and returns a disposer
13
13
  unsubscribing exactly that provider — no other provider's rows and no bridge state are
@@ -35,7 +35,7 @@ interface RowProvider {
35
35
  name?: string; // optional: this plugin's INTEGRATIONS row (§8)
36
36
  rows(ctx: RowContext): CustomizeRow[]; // evaluated lazily, per menu build
37
37
  }
38
- ```
38
+ ```text
39
39
 
40
40
  - `rows()` runs each time the menu is built, never at registration. That is what makes
41
41
  `current()` honest: the plugin reads its live state at build time, so the bullet matches
@@ -91,7 +91,7 @@ const toggle = (value: string) => ({
91
91
  });
92
92
 
93
93
  rows: () => [toggle("activity"), toggle("output")], // one rendered line
94
- ```
94
+ ```text
95
95
 
96
96
  The renderer draws one line, `Name ● current ○ other`, and handles the keys itself: ←/→ moves
97
97
  the cursor inside the line and applies a value only when that row does not defer, Enter applies
@@ -151,7 +151,7 @@ Bash design OK a row name: the host's own vocabulary
151
151
  Bash card → Bash rendering BAD arrow and a repeated qualifier: reads as a breadcrumb
152
152
  Bash card: rendering BAD colon
153
153
  Section: bash-card.status BAD prefix plus a dotted id
154
- ```
154
+ ```text
155
155
 
156
156
  The bridge enforces the shape instead of trusting authors; it is never a silent rewrite:
157
157
 
@@ -304,7 +304,7 @@ told once, on Pi's cross-extension bus.
304
304
  Object.freeze({
305
305
  schema: "gentle-shell-bridge/api", // identity, so a foreign payload is never mistaken for ours
306
306
  version: 1, // the major; a breaking change is a NEW symbol key
307
- VERSION: "0.3.1", // the publishing release; informational only
307
+ VERSION: "0.3.2", // the publishing release; informational only
308
308
  registerRows,
309
309
  registerSection,
310
310
  registerCardStyle, // since 0.2.0; optional on an older published object
@@ -313,7 +313,7 @@ Object.freeze({
313
313
  requestRepaint, // since 0.3.0; optional on an older published object
314
314
  createStore,
315
315
  });
316
- ```
316
+ ```text
317
317
 
318
318
  Publication is **first wins**: a compatible object already in the slot is kept and never replaced,
319
319
  and the slot is never reset. A consumer gates on the schema string, `version === 1` and the three
@@ -359,7 +359,7 @@ pi.on("session_start", (_event, ctx) => {
359
359
  );
360
360
  }
361
361
  });
362
- ```
362
+ ```text
363
363
 
364
364
  Or import the package and call `onBridgeApi(handler, pi.events)`, which does both forms for you: the
365
365
  handler runs immediately when the object is already published (and no listener is taken), otherwise
@@ -374,7 +374,7 @@ const register = (api: BridgeApi): void => {
374
374
 
375
375
  // Works in both orders: this package loaded first, or the plugin did.
376
376
  const dispose = onBridgeApi(register, pi.events);
377
- ```
377
+ ```text
378
378
 
379
379
  `bridgeApi()` never waits, defers or retries: it answers about the slot as it is right now.
380
380
 
@@ -387,7 +387,7 @@ When this package publishes its object, it emits one event on Pi's cross-extensi
387
387
  pi.events.on("gentle-shell-bridge:ready", (payload) => {
388
388
  // { schema: "gentle-shell-bridge/api", version: 1, VERSION: "0.3.0" }
389
389
  });
390
- ```
390
+ ```text
391
391
 
392
392
  - **Name:** `gentle-shell-bridge:ready`. Payload: `{ schema, version, VERSION }` — the same identity,
393
393
  major and release the published object carries, frozen. Informational only: the payload says
@@ -432,7 +432,7 @@ const dispose = api.registerRows({
432
432
  category: "Cards",
433
433
  rows: () => [toggle("activity"), toggle("output")], // consecutive rows → one line
434
434
  });
435
- ```
435
+ ```text
436
436
 
437
437
  Nothing may sit between the two values or the renderer draws two lines. `current()` is evaluated
438
438
  when the menu is built, `action()` runs before the host's own repaint, and `preview()` replaces the
@@ -454,7 +454,7 @@ const dispose = api.registerSection({
454
454
  defaultVisible: true, // absent means true
455
455
  order: 10, // optional integer
456
456
  });
457
- ```
457
+ ```text
458
458
 
459
459
  The bridge generates the `Sections` rows for it: two consecutive rows sharing one label,
460
460
  values `shown` / `hidden`, with a live `current()` and a persist-first `action()`. The label — not
@@ -472,7 +472,7 @@ or writes that file.
472
472
  const store = api.createStore("loon");
473
473
  store.set("bannerSource", "activity");
474
474
  const value = store.get("bannerSource", "activity"); // caller's fallback when absent or unreadable
475
- ```
475
+ ```text
476
476
 
477
477
  The document is `{ "schemaVersion": 1, "values": { … } }` at
478
478
  `<gentle config home>/gentle-shell-bridge/<pluginId>.json`. `values` is an envelope, so a plugin
@@ -548,7 +548,7 @@ const dispose: () => void = registerSection({
548
548
  defaultVisible: true, // absent means true
549
549
  order: 10, // optional integer
550
550
  });
551
- ```
551
+ ```text
552
552
 
553
553
  - `id` — registering an existing id **replaces** the previous section in place; a second
554
554
  card never appears. It is the toggle's identity (replacement, disposer and the persisted
@@ -582,7 +582,7 @@ A row is a plain **string** or a structured **row object**:
582
582
 
583
583
  ```ts
584
584
  { label: string; meta?: string; glyph?: string; tone?: Tone; onClick?: () => void }
585
- ```
585
+ ```text
586
586
 
587
587
  - a plain string is drawn **byte for byte**: the author's characters, clipped to the width, no
588
588
  indent, no padding, no styling. Every section written before structured rows existed looks
@@ -626,7 +626,7 @@ child, and the conversation gets `columns - 53`. One decision IS made from a par
626
626
 
627
627
  ```text
628
628
  const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
629
- ```
629
+ ```text
630
630
  (`lib/shell-sidebar-layout.ts:410`) — a single rail row wider than the content width drops the whole
631
631
  rail, and the conversation then takes every column: the layout's widths changed because of what a
632
632
  part returned.
@@ -706,7 +706,7 @@ label the user reads, never an id and never a prefix:
706
706
  ```text
707
707
  stats ● shown ○ hidden ← the host's own row
708
708
  music ● shown ○ hidden ← a plugin's section (id "loon.music", title "MUSIC")
709
- ```
709
+ ```text
710
710
 
711
711
  For every registered section the bridge emits **two consecutive rows** in category `Sections`,
712
712
  sharing one label as their `choice.setting` (the host prints the setting as the line's name) with
@@ -752,7 +752,7 @@ one namespaced file:
752
752
  "collapsed": { "loon.music": true }
753
753
  }
754
754
  }
755
- ```
755
+ ```text
756
756
 
757
757
  `sections` maps a section id to `true` (shown) / `false` (hidden); a missing id falls back to
758
758
  `defaultVisible`. `collapsed` maps a section id to `true` (folded) / `false` (open); a missing id is
@@ -781,7 +781,7 @@ const dispose: () => void = registerCardStyle({
781
781
  frame: { top, line, bottom }, // the three functions below; nothing else is called
782
782
  preview: () => ({ title: "Cute", sample: "○ cute ╭─ welcome ─╮" }), // optional, literal
783
783
  });
784
- ```
784
+ ```text
785
785
 
786
786
  The id never appears in a row: the host's line reads `Card style ○ neon ● float ○ cute`, and the
787
787
  style's own row is labelled `cute` (or the author's `rowLabel`), exactly like a host row's visible
@@ -794,7 +794,7 @@ text. `label` stays the line's value — the text the host renders and the uniqu
794
794
  top(ctx: { width: number; title?: string; hint?: string }): string;
795
795
  line(ctx: { text: string; width: number; first: boolean }): string;
796
796
  bottom(ctx: { width: number }): string;
797
- ```
797
+ ```text
798
798
 
799
799
  `width` is the width the host rendered at. `title` is the heading the host printed, without its
800
800
  glyph and without the right-aligned hint; `hint` is that hint when its style runs prove it is one
@@ -812,7 +812,7 @@ setting, so the user sees ONE line in the host's own position:
812
812
 
813
813
  ```text
814
814
  Card style ○ neon ● float ○ cute
815
- ```
815
+ ```text
816
816
 
817
817
  The host's own rows keep the host's `current()` and `action()`, wrapped in exactly two ways:
818
818
  `current()` reports `false` while a registered style is selected (one bullet at a time), and
@@ -868,7 +868,7 @@ The selection is the bridge's state, not the plugin's:
868
868
 
869
869
  ```json
870
870
  { "schemaVersion": 1, "values": { "cardStyle": "loon.cute" } }
871
- ```
871
+ ```text
872
872
 
873
873
  `cardStyle` is a style id or `native`. A missing file, an unreadable one, an unknown
874
874
  `schemaVersion` or a non-string value reads as `native`. A stored id whose style was disposed
@@ -928,7 +928,7 @@ const dispose: () => void = registerTransform({
928
928
  return rows;
929
929
  },
930
930
  });
931
- ```
931
+ ```text
932
932
 
933
933
  ### What the transform receives
934
934
 
@@ -1009,7 +1009,7 @@ keeps its report byte for byte:
1009
1009
  transforms: 2 registered
1010
1010
  - loon.bash-cards (order 10): applied, 13 lines, 4 candidates, 1 recognised, 1 rewritten, shapes: float 1
1011
1011
  - loon.strict (registration order): failed (definitions are missing), 13 lines, 0 candidates, 0 recognised, 0 rewritten
1012
- ```
1012
+ ```text
1013
1013
 
1014
1014
  `applied` means the frame changed, `skipped` means the transform was called and left the rows as they
1015
1015
  were, `failed` carries its message, and `not run` means it was registered after the last frame. The
@@ -1034,7 +1034,7 @@ api.registerToolDesign({
1034
1034
  return lines.map((line) => rewriteTheMarkOnly(line));
1035
1035
  },
1036
1036
  });
1037
- ```
1037
+ ```text
1038
1038
 
1039
1039
  ### The convention: lines in, lines out
1040
1040
 
@@ -1199,21 +1199,78 @@ The bridge registers exactly ONE `pi.registerToolRenderer` resolver for the whol
1199
1199
  out to these designs. That is why two plugins contributing designs for the same tool do not stack
1200
1200
  wrappers, and why a plugin must never call `pi.registerToolRenderer` itself for a design.
1201
1201
 
1202
+ ## 15. Plugin keymaps and the command palette
1203
+
1204
+ The bridge publishes what plugins contributed; the **host reads it per build**. The direction is the
1205
+ design: there is no registration handshake, no startup cache and no lifecycle in the host, so a
1206
+ registration made during `session_start` is visible at the next menu or palette open, in any load
1207
+ order.
1208
+
1209
+ ```text
1210
+ Symbol.for("gentle-shell-bridge/plugin-surface/v1") → { schema: 1, keymaps(), commands() }
1211
+ ```text
1212
+
1213
+ Both getters answer with a **copy**, never the live array, so a host that keeps one cannot mutate
1214
+ what the bridge owns.
1215
+
1216
+ ### `registerKeymap(registration): () => void`
1217
+
1218
+ One action for the host's Keymaps page: `{ id, label, env?, defaultKey?, command? }`.
1219
+
1220
+ - `id` and `label` must be non-empty strings; `env`, `defaultKey` and `command` are optional but,
1221
+ when present, must be non-empty strings too. Validation happens here, where the plugin author sees
1222
+ the error.
1223
+ - An `id` starting with `command:` is refused: those name palette command bindings, not plugin
1224
+ actions.
1225
+ - The same `id` registered twice **replaces in place and is reported**, like every other
1226
+ registration in this package. A silent takeover would be one plugin stealing another's row.
1227
+ - The disposer removes exactly the entry it created. A replaced entry is a different object, so
1228
+ disposing the old registration never deletes its successor.
1229
+ - A contributed id can never shadow a built-in action or a `command:<name>` binding: the host keeps
1230
+ its own ids.
1231
+
1232
+ ### `registerCommands(contribution | contribution[]): () => void`
1233
+
1234
+ One palette item, or several: `{ id, label, command, section? }`.
1235
+
1236
+ - An item joins the section it names, after that section's own entries, or opens a section of its
1237
+ own after the host's catalog. Without `section` it lands in the shared `Plugins` page.
1238
+ - The host lists an item only while its command is registered, so a missing extension leaves no
1239
+ dead row, and an own section that would be left empty does not appear at all.
1240
+ - An `id` twice **in one call** is refused whole: the batch would silently keep only the last one.
1241
+ Across calls it replaces in place and is reported, exactly like keymaps.
1242
+
1243
+ ### `resolveKeymap(id): { key?; unavailable? }`
1244
+
1245
+ The key an id currently uses, answered by the shell that owns the file, the environment override
1246
+ and the defaults — environment variable, then saved binding, then default. It never throws: no
1247
+ shell, an older shell or a throwing getter is an `unavailable` reason the caller reports.
1248
+
1249
+ The plugin then binds the key itself with `pi.registerShortcut`. The bridge never binds for a
1250
+ plugin, and the shell never binds a plugin's key. That split is what keeps one implementation of the
1251
+ precedence: the row and the real binding cannot disagree.
1252
+
1253
+ **A keymap change applies after `/reload`.** Pi reads extension shortcuts at startup and on reload,
1254
+ for native actions and contributed ones alike, and it exposes no way to re-register a shortcut while
1255
+ running. The Keymaps page says so, and its last row offers the reload.
1256
+
1202
1257
  ## Limits
1203
1258
 
1204
1259
  - `registerRows`, `registerSection`, `registerCardStyle`, `registerTransform`,
1205
- `registerToolDesign`, `requestRepaint`, `createStore`, `bridgeApi`, `isBridgeApi`, `onBridgeApi`
1260
+ `registerToolDesign`, `registerKeymap`, `registerCommands`, `resolveKeymap`, `requestRepaint`,
1261
+ `createStore`, `bridgeApi`, `isBridgeApi`, `onBridgeApi`
1206
1262
  and the `gentle-shell-bridge:ready` event are the contract; anything a plugin does inside `action()` beyond
1207
1263
  calling inline helpers is the plugin's own business.
1208
- - **`Keymaps` is reserved.** A provider registered with `category: "Keymaps"` is refused before
1209
- anything is stored, with an error naming the category and why (its own key capture and footer; not
1210
- open to plugins yet); the refusal is a rejected registration in the plugin's INTEGRATIONS line and in
1211
- `/bridge-status`. A single row whose own `category` is `Keymaps` is dropped and reported the same way,
1212
- and its siblings still render. The comparison is trimmed and case-insensitive: ` keymaps ` and
1213
- `KEYMAPS` are the same page; `Keymap` and `Key maps` are ordinary categories. `Profiles` and `Reset`
1214
- are not reserved: a plugin row there renders as an ordinary line. A refusal of one row is listed under
1215
- `row rejections` but is not counted in the plugin's INTEGRATIONS line (a build cannot reach it; only the
1216
- registration refusal does).
1264
+ - **`Keymaps` stays reserved for row providers.** A provider registered with `category: "Keymaps"` is
1265
+ refused before anything is stored, with an error naming the category and why; the refusal is a rejected
1266
+ registration in the plugin's INTEGRATIONS line and in `/bridge-status`. A single row whose own
1267
+ `category` is `Keymaps` is dropped and reported the same way, and its siblings still render. The
1268
+ comparison is trimmed and case-insensitive: ` keymaps ` and `KEYMAPS` are the same page; `Keymap` and
1269
+ `Key maps` are ordinary categories. `Profiles` and `Reset` are not reserved: a plugin row there renders
1270
+ as an ordinary line. A refusal of one row is listed under `row rejections` but is not counted in the
1271
+ plugin's INTEGRATIONS line (a build cannot reach it; only the registration refusal does). The reason
1272
+ is unchanged and now has a supported alternative: that page captures keys and claims a footer, so a
1273
+ plain row would be advertised as assignable and could never honour it — a plugin uses §15 instead.
1217
1274
  - **A new category becomes a page.** The host computes its page list once, in the view's constructor
1218
1275
  (`lib/visual-customize-view.ts:387`), so the rows must be in the array before that `map` runs; see
1219
1276
  [architecture.md](architecture.md) "A brand-new page". The cost is stated there. The one residual
@@ -1261,6 +1318,6 @@ wrappers, and why a plugin must never call `pi.registerToolRenderer` itself for
1261
1318
  simply false) and keeps working.
1262
1319
  - A transform is a per-frame pass, so a slow transform is a slow frame; the bridge isolates its
1263
1320
  failures and clips its width, and can bound nothing else. It sees lines, never tool metadata.
1264
- - The API is released (`0.3.0`). The `v1` symbol is the compatibility boundary: a breaking
1321
+ - The API is released (`0.3.2`). The `v1` symbol is the compatibility boundary: a breaking
1265
1322
  change takes a new key instead of mutating this one (§9). The `gentle-shell-bridge:ready` event is
1266
1323
  additive: a plugin that never subscribes loses nothing but the late-load case.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-shell-bridge",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Shared provider bus for gentle-shell's /gentle:customize settings rows: plugins register rows, the bridge owns the single interception layer.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/api.ts CHANGED
@@ -67,7 +67,7 @@ import { createStore } from "./store.ts";
67
67
  import { registerToolDesign } from "./tool-designs.ts";
68
68
 
69
69
  /** The release that published the object; informational, never the compatibility check. */
70
- export const VERSION = "0.3.1";
70
+ export const VERSION = "0.3.2";
71
71
 
72
72
  /** Identity of the object, so a foreign payload at the symbol is never mistaken for ours. */
73
73
  export const API_SCHEMA = "gentle-shell-bridge/api";