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