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 +91 -722
- package/docs/contract.md +92 -35
- package/package.json +1 -1
- package/src/api.ts +1 -1
package/README.md
CHANGED
|
@@ -1,746 +1,115 @@
|
|
|
1
1
|
# gentle-shell-bridge
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
39
|
+
## Core Workflow
|
|
621
40
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
- **
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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.
|
|
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`, `
|
|
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`
|
|
1209
|
-
anything is stored, with an error naming the category and why
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
`
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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";
|