gentle-shell-bridge 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codegraph/.gitignore +5 -0
- package/PRD.md +144 -0
- package/README.md +745 -2
- package/docs/architecture.md +981 -0
- package/docs/contract.md +1266 -0
- package/docs/state.md +251 -0
- package/examples/bash-card.ts +1694 -0
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +30 -0
- package/odd/tasks/plugin-keymap.md +32 -0
- package/odd/tasks/plugin-surface-contract.md +55 -0
- package/package.json +35 -4
- package/src/api.ts +492 -0
- package/src/arm.ts +83 -0
- package/src/bus.ts +558 -0
- package/src/capture.ts +886 -0
- package/src/card-style-line.ts +112 -0
- package/src/card-style-rows.ts +390 -0
- package/src/card-style-transform.ts +862 -0
- package/src/card-style-wiring.ts +472 -0
- package/src/card-styles.ts +159 -0
- package/src/config.ts +71 -0
- package/src/extension.ts +271 -0
- package/src/hook.ts +310 -0
- package/src/index.ts +71 -0
- package/src/notices.ts +137 -0
- package/src/plugin-lines.ts +865 -0
- package/src/plugin-surface.ts +218 -0
- package/src/presence.ts +259 -0
- package/src/repaint.ts +351 -0
- package/src/row-labels.ts +146 -0
- package/src/row-names.ts +273 -0
- package/src/row-origin.ts +144 -0
- package/src/rows.ts +248 -0
- package/src/section-panel.ts +378 -0
- package/src/section-pointer.ts +164 -0
- package/src/sections-rows.ts +128 -0
- package/src/sidebar-render.ts +535 -0
- package/src/sidebar.ts +919 -0
- package/src/status.ts +1161 -0
- package/src/store.ts +279 -0
- package/src/tool-design-adoption.ts +263 -0
- package/src/tool-design-rows.ts +246 -0
- package/src/tool-design-wiring.ts +428 -0
- package/src/tool-designs.ts +402 -0
- package/src/transform-pipeline.ts +351 -0
- package/src/types.ts +442 -0
- package/src/visibility.ts +154 -0
- package/test/api.test.ts +395 -0
- package/test/apply-on-move.test.ts +477 -0
- package/test/arm.test.ts +220 -0
- package/test/bridge-ready.test.ts +309 -0
- package/test/bus.test.ts +165 -0
- package/test/button-rows.test.ts +409 -0
- package/test/capture.test.ts +356 -0
- package/test/card-style-rows.test.ts +510 -0
- package/test/card-style-transform.test.ts +379 -0
- package/test/card-style-wiring.test.ts +258 -0
- package/test/card-styles-e2e.test.ts +491 -0
- package/test/card-styles.test.ts +324 -0
- package/test/card-transcript-path.test.ts +178 -0
- package/test/config.test.ts +93 -0
- package/test/e2e.test.ts +443 -0
- package/test/example-bash-identity.test.ts +372 -0
- package/test/example-bash-matrix.test.ts +856 -0
- package/test/example-designs.test.ts +734 -0
- package/test/example-neon-transform.test.ts +283 -0
- package/test/example-plain-style.test.ts +1268 -0
- package/test/extension.test.ts +160 -0
- package/test/fake-events.ts +58 -0
- package/test/fake-host.ts +211 -0
- package/test/fake-sidebar.ts +516 -0
- package/test/fake-transcript.ts +486 -0
- package/test/frame-telemetry.test.ts +417 -0
- package/test/hook.test.ts +196 -0
- package/test/manifest.test.ts +105 -0
- package/test/new-category.test.ts +979 -0
- package/test/notices.test.ts +328 -0
- package/test/plugin-integration-lines.test.ts +979 -0
- package/test/plugin-surface.test.ts +152 -0
- package/test/presence.test.ts +459 -0
- package/test/rail-surfaces.test.ts +457 -0
- package/test/rail-width.test.ts +423 -0
- package/test/regression.test.ts +483 -0
- package/test/repaint-api.test.ts +200 -0
- package/test/reserved-categories.test.ts +361 -0
- package/test/row-labels.test.ts +284 -0
- package/test/row-names.test.ts +473 -0
- package/test/row-origin.test.ts +395 -0
- package/test/section-interaction.test.ts +364 -0
- package/test/section-panel.test.ts +403 -0
- package/test/sections-rows.test.ts +291 -0
- package/test/sidebar-backend.test.ts +471 -0
- package/test/sidebar-e2e.test.ts +331 -0
- package/test/sidebar-hover.test.ts +276 -0
- package/test/sidebar-independent.test.ts +349 -0
- package/test/sidebar-render.test.ts +281 -0
- package/test/sidebar-sections.test.ts +245 -0
- package/test/status-card-styles.test.ts +201 -0
- package/test/status-section-frame.test.ts +181 -0
- package/test/status-sections.test.ts +243 -0
- package/test/status.test.ts +209 -0
- package/test/store.test.ts +192 -0
- package/test/surface-repaint-cost.test.ts +520 -0
- package/test/tool-design-chain.test.ts +483 -0
- package/test/tool-design-cost.test.ts +473 -0
- package/test/tool-design-rows.test.ts +359 -0
- package/test/tool-designs.test.ts +745 -0
- package/test/transform-pipeline.test.ts +465 -0
- package/test/transforms-bus.test.ts +233 -0
- package/test/verifier-width-regressions.test.ts +164 -0
- package/tsconfig.json +18 -0
package/docs/contract.md
ADDED
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
# Contract — `registerRows`, providers, rows, diagnostics
|
|
2
|
+
|
|
3
|
+
What a plugin can rely on. The mechanism behind it is in
|
|
4
|
+
[architecture.md](architecture.md); the persistence decision in [state.md](state.md).
|
|
5
|
+
|
|
6
|
+
## 1. `registerRows(provider): () => void`
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const dispose: () => void = registerRows(provider);
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Registers a row provider with the process-global bridge and returns a disposer
|
|
13
|
+
unsubscribing exactly that provider — no other provider's rows and no bridge state are
|
|
14
|
+
touched. It is safe to call from `session_start`, including after a rebind: the bridge
|
|
15
|
+
arms once, and a provider registered twice under the same `id` replaces its previous
|
|
16
|
+
registration instead of stacking rows.
|
|
17
|
+
|
|
18
|
+
Registration does **not** open the menu. Rows are built when a menu is actually
|
|
19
|
+
constructed.
|
|
20
|
+
|
|
21
|
+
The package's public entry exports exactly `registerRows`, `registerSection`, `createStore`,
|
|
22
|
+
`STORE_SCHEMA_VERSION`, `VERSION`, the runtime helpers `bridgeApi`, `isBridgeApi`, `onBridgeApi`,
|
|
23
|
+
the announcement channel `BRIDGE_READY_EVENT` (§9), and
|
|
24
|
+
the row/provider/section/store/API types. `snapshotProviders`, `snapshotSections`,
|
|
25
|
+
`publishBridgeApi`, `announceBridgeReady` and every `…ForTests` seam are **internal**: they exist so
|
|
26
|
+
the bridge's own backends and tests can drive one process, and they are not part of this
|
|
27
|
+
contract.
|
|
28
|
+
|
|
29
|
+
## 2. Provider
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
interface RowProvider {
|
|
33
|
+
id: string; // unique, stable, e.g. "loon.banner-source"
|
|
34
|
+
category: string; // a category gentle-shell already renders
|
|
35
|
+
name?: string; // optional: this plugin's INTEGRATIONS row (§8)
|
|
36
|
+
rows(ctx: RowContext): CustomizeRow[]; // evaluated lazily, per menu build
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `rows()` runs each time the menu is built, never at registration. That is what makes
|
|
41
|
+
`current()` honest: the plugin reads its live state at build time, so the bullet matches
|
|
42
|
+
reality before the user touches anything.
|
|
43
|
+
- `ctx` carries at least the active theme and the resolved gentle-shell config home. The
|
|
44
|
+
theme is the one Pi passes to the factory (`factory(this.ui, theme, this.keybindings,
|
|
45
|
+
close)`, `interactive-mode.ts:3136`); the config home is the directory gentle-shell
|
|
46
|
+
resolves for its own files (default `~/.pi/gentle-ai`, `GENTLE_PI_CONFIG_HOME` override —
|
|
47
|
+
`lib/gentle-shell-bundled.ts:231-239`), so a plugin can place per-user state without
|
|
48
|
+
re-deriving the path.
|
|
49
|
+
- `id` is the handle used by ordering, replacement and the diagnostics report. Two
|
|
50
|
+
providers may share an `id` only by replacing each other; two providers with different
|
|
51
|
+
`id`s but the same `category` + `setting` conflict (see §6).
|
|
52
|
+
|
|
53
|
+
## 3. Row fields
|
|
54
|
+
|
|
55
|
+
All of these are already understood by gentle-shell's renderer
|
|
56
|
+
(`lib/visual-customize-view.ts:107-128`); the bridge carries them through unchanged, with one
|
|
57
|
+
deliberate exception: it sets `choice.applyOnMove: false` on every row it contributes (§4).
|
|
58
|
+
|
|
59
|
+
| Field | Type | Behaviour |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `category` | string (existing category) | which menu page the row joins |
|
|
62
|
+
| `label` | `string \| (() => string)` | row text; a function is evaluated when the menu is built |
|
|
63
|
+
| `choice` | `{ setting, value, current(), hint?, applyOnMove? }` | one value of a setting; consecutive rows of the same `setting` share a line. The bridge forces `applyOnMove: false` (§4) |
|
|
64
|
+
| `preview()` | `{ title, sample }` | read-only depiction of the highlighted choice, never an application |
|
|
65
|
+
| `visible()` | `() => boolean` | hidden when false; a throw fails closed (row hidden) |
|
|
66
|
+
| `keyhint` | string | footer hint for this row instead of the category default |
|
|
67
|
+
| `action(inline)` | `(inline) => void \| Promise<void>` | Enter/Space handler; may be async |
|
|
68
|
+
|
|
69
|
+
Inline helpers available to `action` (`lib/visual-customize-view.ts:129-140`):
|
|
70
|
+
|
|
71
|
+
- `inline.input({ prompt, value })` — inline field on the highlighted row; resolves the
|
|
72
|
+
submitted string or `undefined` on cancel.
|
|
73
|
+
- `inline.confirm(message)` — inline confirmation; resolves `true` only after an explicit,
|
|
74
|
+
fully visible yes.
|
|
75
|
+
- `inline.capture(prompt)` — next raw key press; resolves the key or `undefined` on Esc.
|
|
76
|
+
|
|
77
|
+
They exist so a row never opens a nested native dialog; a plugin that needs a value should
|
|
78
|
+
use them rather than `ctx.ui.input`.
|
|
79
|
+
|
|
80
|
+
## 4. How a toggle works
|
|
81
|
+
|
|
82
|
+
A toggle is not a widget type. Push two consecutive rows sharing the same `choice.setting`
|
|
83
|
+
with different `value`s:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const toggle = (value: string) => ({
|
|
87
|
+
category: "Layout" as const,
|
|
88
|
+
label: () => "Loon banner source",
|
|
89
|
+
choice: { setting: "Loon banner source", value, current: () => read() === value },
|
|
90
|
+
action: () => store.set("bannerSource", value),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
rows: () => [toggle("activity"), toggle("output")], // one rendered line
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The renderer draws one line, `Name ● current ○ other`, and handles the keys itself: ←/→ moves
|
|
97
|
+
the cursor inside the line and applies a value only when that row does not defer, Enter applies
|
|
98
|
+
whatever is focused (`lib/visual-customize-view.ts:552-577`). "Consecutive" is literal — a row
|
|
99
|
+
between the two values splits them into two lines.
|
|
100
|
+
|
|
101
|
+
### Moving never applies: every bridge row defers to Enter
|
|
102
|
+
|
|
103
|
+
A line the bridge contributes is navigated, not switched under the user's fingers: ←/→ moves the
|
|
104
|
+
`›` cursor and Enter applies. That is one row field, so it is one rule for every category:
|
|
105
|
+
|
|
106
|
+
- every row the bridge itself builds — the `Sections` toggle, the card-style values in both modes
|
|
107
|
+
(the host's `Card style` line extended in place, and the fallback `Bridge card style` line)
|
|
108
|
+
including `native`, and every value of a tool design's `Cards` line — carries
|
|
109
|
+
`choice.applyOnMove: false`;
|
|
110
|
+
- `buildRows` enforces it for a plugin's own provider rows too, by shallow-copying the row and its
|
|
111
|
+
`choice` (the provider's objects are never mutated, and every other field is the same reference).
|
|
112
|
+
An authored `applyOnMove: true` is overridden: the bridge owns the integration and the design is
|
|
113
|
+
one behaviour for every row it adds, so the field has no author-facing meaning here;
|
|
114
|
+
- the host's OWN rows are untouched. `Card style · neon/float` still applies on ←/→ because the
|
|
115
|
+
host wrote it that way, so a line holding both behaves per row — which is exactly the host's rule.
|
|
116
|
+
|
|
117
|
+
While such a row is focused the host's footer reads `←/→ choose · Enter apply` instead of
|
|
118
|
+
`←/→ change` (`lib/visual-customize-view.ts:35-38,751`), and moving costs nothing: no store write,
|
|
119
|
+
no persisted value, no repaint request. Enter runs `action()` exactly once, which is where the
|
|
120
|
+
bridge's write path (persist first, repaint second) lives.
|
|
121
|
+
|
|
122
|
+
Other row kinds follow from the same shape:
|
|
123
|
+
|
|
124
|
+
- **Command row** — no `choice`, only `action`: one line, Enter runs it.
|
|
125
|
+
- **Gated row** — `visible()` returns `false` (or throws) and the row disappears.
|
|
126
|
+
- **Preview row** — `preview()` supplies the read-only headline and sample.
|
|
127
|
+
- **Inline editor row** — `action` opens `inline.input()`, asks with `inline.confirm()`, or
|
|
128
|
+
captures a key with `inline.capture()`.
|
|
129
|
+
|
|
130
|
+
### Two kinds of row: toggle and button
|
|
131
|
+
|
|
132
|
+
A row is one of two first-class kinds. A **toggle** has a `choice`; consecutive rows sharing
|
|
133
|
+
`choice.setting` render as one line. A **button** has a `label` and an `action` and NO `choice`; Enter
|
|
134
|
+
runs the action, the shape of gentle-shell's own `Profiles` and `Reset` rows. A button's label is its
|
|
135
|
+
whole visible text, so the row-name rule below applies to it exactly as to a toggle's label (one to three
|
|
136
|
+
plain words, no structural marks; refused and reported, never rewritten). `preview`, `keyhint` and
|
|
137
|
+
`visible` pass through untouched, and the bridge injects neither a `choice` nor an `applyOnMove`. Its
|
|
138
|
+
one change is the `action`, wrapped so a throw or a rejected promise is contained and recorded once in
|
|
139
|
+
`/bridge-status` under `notices`, while the menu and every other row stay usable; the provider's object
|
|
140
|
+
is never mutated, so the host receives a copy.
|
|
141
|
+
|
|
142
|
+
### Row names
|
|
143
|
+
|
|
144
|
+
`choice.setting` (the line's name) and `label` (the row's text) are what a human reads, so both obey
|
|
145
|
+
ONE shape: **one to three plain words**, title case like the host's own rows (`Card style`,
|
|
146
|
+
`Card content`), and **none of** `→ ← ↑ ↓ : . | / \ [ ] ( ) { }`. No trailing qualifier — the
|
|
147
|
+
category already says where the row lives, and the values already say what the choices are:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
Bash design OK a row name: the host's own vocabulary
|
|
151
|
+
Bash card → Bash rendering BAD arrow and a repeated qualifier: reads as a breadcrumb
|
|
152
|
+
Bash card: rendering BAD colon
|
|
153
|
+
Section: bash-card.status BAD prefix plus a dotted id
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The bridge enforces the shape instead of trusting authors; it is never a silent rewrite:
|
|
157
|
+
|
|
158
|
+
- a provider row whose line name or label breaks it is **dropped from the menu build** — absent
|
|
159
|
+
from the rows the host receives AND from the rows `/bridge-status` compares for duplicates, so it
|
|
160
|
+
is not part of the effective set;
|
|
161
|
+
- a section or a card style whose `rowLabel` breaks it is **not registered at all**;
|
|
162
|
+
- the offending string is recorded verbatim with the rule it broke, and `/bridge-status` lists it
|
|
163
|
+
under `row rejections`. The menu never shows a sanitized version of a name the bridge refused,
|
|
164
|
+
so the exact string in the report is the one to fix.
|
|
165
|
+
|
|
166
|
+
Derived names (a section `title`, a card style `label`) cannot violate the shape: derivation drops
|
|
167
|
+
every non-word character — marks included — and clamps the result to the same word limit. The
|
|
168
|
+
clamp is the only transformation the bridge performs, and only on text it derives itself.
|
|
169
|
+
|
|
170
|
+
## 5. Ordering and idempotency
|
|
171
|
+
|
|
172
|
+
- Providers apply in subscription order; their rows are appended in that order, so a
|
|
173
|
+
deterministic provider set gives a deterministic menu.
|
|
174
|
+
- Disposing one provider leaves the others' relative order intact.
|
|
175
|
+
- Arming is idempotent across `session_start`, `/reload` and double loads: one interception
|
|
176
|
+
layer, one injection per captured row array (non-enumerable marker on the array), one
|
|
177
|
+
wrapper per UI object (non-enumerable marker on the target).
|
|
178
|
+
- Registering the same `id` twice yields one provider's rows and one diagnostics entry.
|
|
179
|
+
|
|
180
|
+
## 6. Conflicts
|
|
181
|
+
|
|
182
|
+
Same `category` + same `setting` from two different providers has one of two outcomes,
|
|
183
|
+
determined by insertion order:
|
|
184
|
+
|
|
185
|
+
- **Consecutive** in the captured array → the renderer coalesces them into a single line
|
|
186
|
+
whose bullets are shared, with the two providers' `action`s behind the same setting
|
|
187
|
+
(`lib/visual-customize-view.ts:395-401`).
|
|
188
|
+
- **Not consecutive** → two separate lines in a category, each with its own bullets, which
|
|
189
|
+
reads as a duplicate to the user.
|
|
190
|
+
|
|
191
|
+
Recommended policy: **pick a distinct plain-word name**, e.g. `"Loon banner source"` rather than
|
|
192
|
+
`"Banner source"`. Namespacing with punctuation (`"Loon → Banner source"`) is not an option: the
|
|
193
|
+
row-name shape forbids arrows and colons, and the bridge rejects such a name before it is stored
|
|
194
|
+
(see §4, "Row names"). The bridge warns on duplicate `category` + `setting` pairs and reports them
|
|
195
|
+
through `/bridge-status`, comparing the rows of the last menu build (`no build yet` before the first
|
|
196
|
+
build); it does not rename rows, because a row's setting name is its identity in the renderer.
|
|
197
|
+
|
|
198
|
+
## 7. Failure isolation
|
|
199
|
+
|
|
200
|
+
A provider that throws while its rows are built loses its rows and nothing else:
|
|
201
|
+
|
|
202
|
+
- the menu still opens, with the other providers' rows;
|
|
203
|
+
- the failure is recorded as the last failure for `/bridge-status`, with the provider `id` and the
|
|
204
|
+
error message;
|
|
205
|
+
- the remaining providers still apply, in order.
|
|
206
|
+
|
|
207
|
+
The same applies to an exception inside a row's `visible()` (the row is hidden) and to a
|
|
208
|
+
rejection from `action()`. Both sync throws and async rejections are routed by the view's
|
|
209
|
+
`withBusy()` to `options.onError` (`lib/visual-customize-view.ts:224-235`), which
|
|
210
|
+
gentle-shell already wires to an error notification; the menu stays usable.
|
|
211
|
+
|
|
212
|
+
## 8. Diagnostics: `/bridge-status`
|
|
213
|
+
|
|
214
|
+
One command, `/bridge-status`, reporting:
|
|
215
|
+
|
|
216
|
+
| Field | Meaning |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| Providers | every registered provider, `id` and `category`, in subscription order |
|
|
219
|
+
| Backend | which interception backend is active: `hook` or `capture` |
|
|
220
|
+
| Last capture | outcome of the most recent menu build, with the number of rows injected |
|
|
221
|
+
| Last failures | most recent provider failures: `id`, message, and whether rows were dropped |
|
|
222
|
+
| Store problems | store files this process could not use, with the plugin id and the reason |
|
|
223
|
+
| Duplicate settings | every `category` + `setting` pair contributed by more than one provider in the rows of the last menu build; `no build yet` before the first build |
|
|
224
|
+
| Sections | every registered section with its id, title, the `row` label the menu reads (and the collision note when two wanted the same label), persisted visibility (plus `collapsed` while the card is folded) and where its position comes from (`order 3` or `registration order`); omitted when no section is registered. Ids stay here on purpose: this report is for the plugin author, not the user picking a toggle |
|
|
225
|
+
| Section frame | which drawing the section header and rows use: `host` when the host's own `renderPanelHeader`/`renderPanelRow` were found on an object the bridge is handed, `fallback` otherwise — with what was looked for, whether Pi has handed the widget factory a theme, and (for `fallback`) a `section frame gap` line naming what cannot be reached. Emitted with the sections table |
|
|
226
|
+
| Section interaction | how the pointer reaches the rows: the host's own `handleMouse` dispatch, and how the hover claim is carried (`hover owner claimed on the host's sidebar state`, `unavailable` with the reason, or `not used yet`). Emitted with the sections table |
|
|
227
|
+
| Section failures | the most recent sidebar frame's failing sections with `id`, `phase` (`lines`/`meta`/`onClick`) and message |
|
|
228
|
+
| Sidebar | `intercepted (rail wrapped)`, `intercepted (rail pending)`, `no-sidebar-state`, or `not armed` with the reason the wiring could not acquire a `tui` |
|
|
229
|
+
| Transforms | every registered transcript transform in pipeline order (`order 3` or `registration order`) with the last frame's outcome: `applied`, `skipped`, `failed` with the message, or `not run`; omitted when no transform is registered |
|
|
230
|
+
| Card style surfaces | per surface (`conversation`, `rail`): whether the selected style reached it and what its last frame did there (`transformed N cards`, `pass-through (native / missing / no card)`, `failed` with the style and message); `not reached` carries the attach outcome (`no-layout-root`, `no-sidebar`, `no-rail`, …). Emitted with the card-style block |
|
|
231
|
+
| Last repaint | the most recent repaint a bridge-owned write requested: the surfaces, the cause (`section "loon.music" → hidden`, `card style → loon.cute`) and `repainted` or `unavailable` with the reason. Omitted until such a write happens |
|
|
232
|
+
| Row name rejections | every line name and row label the bridge refused (`rows`, `section` or `card style`), with the surface, the registration `id`, the field (`line name`/`row label`), the offending string VERBATIM and the rule it broke; omitted when nothing was rejected |
|
|
233
|
+
| Tool designs | every tool with designs: the selection in force (`native` or a design id, `<id> (missing: not registered)` when it no longer exists), whether the one resolver has mounted a card for it (`wrapper mounted on 2 cards` / `wrapper not mounted`), how many cards the post-construction adoption pass reached for it (`adopted 1 card`, S10), each registered design's id, label and description; then the resolver's last outcome and how many designs it registered, and any conflicts or failures. Omitted when the process registered no design |
|
|
234
|
+
| Plugin lines | one entry per plugin that registered (`rows`, `section`, `card style`, `transform` or `tool design`): its row name and whether it was `derived from package "@acme/loon-kit"` or an `explicit name`, the registrations `accepted` and `rejected`, any later `name conflict` that declared a different name for the same plugin (with the name kept), and whether the line is `set` or, with the reason, `no line set`. The header also says when the host's `setStatus` was unavailable. Omitted when no plugin registered so far |
|
|
235
|
+
|
|
236
|
+
A menu that opened without the bridge's rows must show up here — armed-but-uncaptured,
|
|
237
|
+
shape mismatch, or provider failure. A registered section that never appears must show up
|
|
238
|
+
too: the sections table proves the registration, and the sidebar line proves whether the rail
|
|
239
|
+
was ever reached. Diagnostics never run a provider's `rows()` and never run a section's
|
|
240
|
+
`lines()` or `meta()`: both are UI, and a report is not a frame.
|
|
241
|
+
|
|
242
|
+
### The plugin's INTEGRATIONS line
|
|
243
|
+
|
|
244
|
+
`ctx.ui.setStatus(key, text)` paints one row in the host's INTEGRATIONS card, and `undefined`
|
|
245
|
+
removes it. The bridge sets exactly **one** row per plugin, no matter how many rows, sections,
|
|
246
|
+
card styles or transforms that plugin registers (one plugin, one line).
|
|
247
|
+
|
|
248
|
+
- **The text is opaque.** The host renders the whole string as one row; it is not parsed into a
|
|
249
|
+
key and a value, so everything the row says must be inside it: `Loon kit · ready`. The host's
|
|
250
|
+
`sanitizeStatus` strips ANSI sequences and collapses whitespace, so **ANSI is not yours** and a
|
|
251
|
+
glyph must be part of the text itself (the host's own fixture row is `🧠 gentle-pi · ready`).
|
|
252
|
+
- **Colour belongs to the host.** Every entry is painted with one shared status role. A plugin
|
|
253
|
+
cannot choose a colour; it can only choose a glyph.
|
|
254
|
+
- **The name is derived, or given once.** The bridge captures the module that registered, resolves
|
|
255
|
+
it to its owning `package.json` and derives one to three plain words from its `name`
|
|
256
|
+
(`@acme/loon-kit` → `Loon kit`). The answer is cached per module. A module with no owning package
|
|
257
|
+
(a single copied file) gets NO row, and `/bridge-status` says why. The Pi installation itself is
|
|
258
|
+
never a plugin identity: a module owned by the package that provides `pi` (the package that owns
|
|
259
|
+
the running `pi` entry point, or one whose manifest declares the `pi` executable) fails closed the
|
|
260
|
+
same way, so a shared extensions directory cannot produce a `Pi coding agent` row.
|
|
261
|
+
- **Identity is the plugin, and the name is sticky.** A plugin identity is its owning package root
|
|
262
|
+
(every module in that package is the same plugin), or the registering module when no package owns
|
|
263
|
+
it. It is resolved once and cached; the footer key is hashed from that identity, never from the
|
|
264
|
+
display name, so a rename — of the package or of the name — repaints the SAME key and leaves no
|
|
265
|
+
orphan row. Any registration kind accepts an optional `name`: the first registration that declares
|
|
266
|
+
a usable one fixes the name for the identity, and later registrations inherit it whether or not
|
|
267
|
+
they repeat it. A later DIFFERENT name does not rename and never adds a row; the first name is
|
|
268
|
+
kept and the conflict is reported in `/bridge-status`. A name is attribution, not mutable state;
|
|
269
|
+
a deliberate rename ships as an edit plus `/reload`, a new registration lifetime.
|
|
270
|
+
- **The name obeys the row-name shape** (§4, Row names). A derived name is cleaned; when nothing
|
|
271
|
+
usable is left there is no row. An explicit name is validated, never rewritten: a rejected name
|
|
272
|
+
produces no row at all (when it is the first registration's), and a later rejected name is
|
|
273
|
+
reported without discarding the name the plugin already has.
|
|
274
|
+
- **The state is derived, never promised.** `ready` means every registration the bridge accepted;
|
|
275
|
+
one rejected registration (an invalid id, a row label that broke the shape rule) repaints the same
|
|
276
|
+
key as `degraded`, and this report names the rejected items.
|
|
277
|
+
- **One key, painted in place.** A plugin's key is stable for its lifetime; every later change
|
|
278
|
+
repaints that same key, and `session_shutdown` clears it with `undefined`. A `ctx.ui` without
|
|
279
|
+
`setStatus` skips the whole feature and is reported here instead of throwing into a registration.
|
|
280
|
+
|
|
281
|
+
The row name is the diagnostic column's label; the key and the module path stay in this report,
|
|
282
|
+
because it is read by the plugin author, not by the user reading the footer.
|
|
283
|
+
|
|
284
|
+
## 9. Reaching the bridge — `session_start`, or the announcement
|
|
285
|
+
|
|
286
|
+
**The rule: register at `session_start` if the package loaded first, otherwise when the
|
|
287
|
+
announcement arrives.** Pi loads every extension module and waits for its async initialization
|
|
288
|
+
before it fires `session_start` (`docs/extensions.md:213`), and it fires that event for `startup`,
|
|
289
|
+
`reload`, `new`, `resume` and `fork` (`:393`). But it does not guarantee that THIS package loaded
|
|
290
|
+
before the plugin: global extensions (`~/.pi/agent/extensions`) load — and receive `session_start` —
|
|
291
|
+
before a configured package's module publishes. A real TUI showed exactly that, with the plugin's
|
|
292
|
+
handler finding no symbol while the bridge went on to publish a moment later.
|
|
293
|
+
|
|
294
|
+
So an absent symbol at `session_start` is **"not yet"**, not "not installed". The symbol stays the
|
|
295
|
+
only authority for *which* object to use; the announcement below is how a plugin learns that a
|
|
296
|
+
later publication happened. The bridge publishes its object as its module loads, and the process is
|
|
297
|
+
told once, on Pi's cross-extension bus.
|
|
298
|
+
|
|
299
|
+
### What is published
|
|
300
|
+
|
|
301
|
+
`Symbol.for("gentle-shell-bridge/api/v1")` holds exactly:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
Object.freeze({
|
|
305
|
+
schema: "gentle-shell-bridge/api", // identity, so a foreign payload is never mistaken for ours
|
|
306
|
+
version: 1, // the major; a breaking change is a NEW symbol key
|
|
307
|
+
VERSION: "0.3.0", // the publishing release; informational only
|
|
308
|
+
registerRows,
|
|
309
|
+
registerSection,
|
|
310
|
+
registerCardStyle, // since 0.2.0; optional on an older published object
|
|
311
|
+
registerTransform, // since 0.3.0; optional on an older published object
|
|
312
|
+
registerToolDesign, // since 0.3.0; optional on an older published object
|
|
313
|
+
requestRepaint, // since 0.3.0; optional on an older published object
|
|
314
|
+
createStore,
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Publication is **first wins**: a compatible object already in the slot is kept and never replaced,
|
|
319
|
+
and the slot is never reset. A consumer gates on the schema string, `version === 1` and the three
|
|
320
|
+
callable members below — `registerCardStyle`, `registerTransform`, `registerToolDesign` and
|
|
321
|
+
`requestRepaint` are NOT part of the gate, so an object published by an older copy is still a usable
|
|
322
|
+
bridge for rows, sections and the store; a plugin that needs a later surface checks
|
|
323
|
+
`typeof api.registerCardStyle === "function"` (or `registerTransform`, `registerToolDesign`,
|
|
324
|
+
`requestRepaint`) before calling it. Anything else — another library's
|
|
325
|
+
payload, another major, a partial object — is
|
|
326
|
+
**absent**, never an error: the object is not used, the slot is left exactly as it is (never
|
|
327
|
+
overwritten, deleted or reset), and one notice per process names the mismatch. `v1` in the key is
|
|
328
|
+
the major: a breaking change is a new `v2` key, never a mutation of this one.
|
|
329
|
+
|
|
330
|
+
### Two ways to read it
|
|
331
|
+
|
|
332
|
+
Read the symbol directly — no import, the form the worked example uses. The `session_start` attempt
|
|
333
|
+
is kept, and the announcement covers a bridge that publishes after this file loaded:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
const KEY = Symbol.for("gentle-shell-bridge/api/v1");
|
|
337
|
+
const isApi = (value: any): boolean =>
|
|
338
|
+
typeof value === "object" && value !== null &&
|
|
339
|
+
value.schema === "gentle-shell-bridge/api" && value.version === 1 &&
|
|
340
|
+
typeof value.registerRows === "function" &&
|
|
341
|
+
typeof value.registerSection === "function" &&
|
|
342
|
+
typeof value.createStore === "function";
|
|
343
|
+
|
|
344
|
+
const register = (): boolean => {
|
|
345
|
+
const api = (globalThis as any)[KEY];
|
|
346
|
+
if (!isApi(api)) return false;
|
|
347
|
+
// api.registerRows(...) / api.registerSection(...) / api.createStore(...)
|
|
348
|
+
return true;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const registered = register();
|
|
352
|
+
if (!registered) pi.events?.on("gentle-shell-bridge:ready", register);
|
|
353
|
+
|
|
354
|
+
pi.on("session_start", (_event, ctx) => {
|
|
355
|
+
if (!register()) {
|
|
356
|
+
ctx.ui?.notify(
|
|
357
|
+
"my-plugin: waiting for gentle-shell-bridge to announce itself",
|
|
358
|
+
"warning",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Or import the package and call `onBridgeApi(handler, pi.events)`, which does both forms for you: the
|
|
365
|
+
handler runs immediately when the object is already published (and no listener is taken), otherwise
|
|
366
|
+
it runs when the announcement arrives, once, after which the subscription removes itself.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { onBridgeApi, type BridgeApi } from "gentle-shell-bridge";
|
|
370
|
+
|
|
371
|
+
const register = (api: BridgeApi): void => {
|
|
372
|
+
// api.registerRows(...) / api.registerSection(...) / api.createStore(...)
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Works in both orders: this package loaded first, or the plugin did.
|
|
376
|
+
const dispose = onBridgeApi(register, pi.events);
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`bridgeApi()` never waits, defers or retries: it answers about the slot as it is right now.
|
|
380
|
+
|
|
381
|
+
### The announcement: `gentle-shell-bridge:ready`
|
|
382
|
+
|
|
383
|
+
When this package publishes its object, it emits one event on Pi's cross-extension bus
|
|
384
|
+
(`docs/extensions.md:1965-1972`), the same channel gentle-shell's other extensions use:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
pi.events.on("gentle-shell-bridge:ready", (payload) => {
|
|
388
|
+
// { schema: "gentle-shell-bridge/api", version: 1, VERSION: "0.3.0" }
|
|
389
|
+
});
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
- **Name:** `gentle-shell-bridge:ready`. Payload: `{ schema, version, VERSION }` — the same identity,
|
|
393
|
+
major and release the published object carries, frozen. Informational only: the payload says
|
|
394
|
+
*which* bridge announced itself, never *what* it can do.
|
|
395
|
+
- **Emitted once per process**, after a successful publish, no matter how many copies of the package
|
|
396
|
+
Pi loaded (`moduleCache: false` gives it one module instance per extension, not one per process).
|
|
397
|
+
- **Never for an incompatible slot.** If the symbol holds another library's payload or another major,
|
|
398
|
+
nothing is announced: there is no object to send a listener to, and `bridgeApi()` stays
|
|
399
|
+
`undefined`.
|
|
400
|
+
- **Silent without a bus.** On a Pi with no `pi.events`, nothing is emitted and nothing is reported;
|
|
401
|
+
the symbol still works for any plugin that reads it.
|
|
402
|
+
- **It is a notification, not a registry.** The listener still reads the symbol and applies the gate:
|
|
403
|
+
an announcement that is not backed by a compatible object is ignored. That is why the event can
|
|
404
|
+
never hand out a half-working API.
|
|
405
|
+
|
|
406
|
+
The order that matters: the announcement may arrive before or after the plugin's `session_start`,
|
|
407
|
+
and registration is idempotent by stable id, so a plugin may register from both paths.
|
|
408
|
+
|
|
409
|
+
### Surface 1: rows for a customize category
|
|
410
|
+
|
|
411
|
+
One provider contributes rows to a category the host already renders. A toggle is two
|
|
412
|
+
**consecutive** rows sharing one `choice.setting`:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
const store = api.createStore("loon");
|
|
416
|
+
const read = () => store.get("bannerSource", "activity");
|
|
417
|
+
|
|
418
|
+
const toggle = (value: string) => ({
|
|
419
|
+
category: "Cards" as const,
|
|
420
|
+
label: () => "Loon banner source",
|
|
421
|
+
choice: {
|
|
422
|
+
setting: "Loon banner source",
|
|
423
|
+
value,
|
|
424
|
+
current: () => read() === value, // live: read at render time, so the ● never lies
|
|
425
|
+
},
|
|
426
|
+
preview: () => ({ title: value, sample: "literal depiction of this choice" }),
|
|
427
|
+
action: () => store.set("bannerSource", value), // persist FIRST; the host repaints after
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const dispose = api.registerRows({
|
|
431
|
+
id: "loon.banner-source",
|
|
432
|
+
category: "Cards",
|
|
433
|
+
rows: () => [toggle("activity"), toggle("output")], // consecutive rows → one line
|
|
434
|
+
});
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Nothing may sit between the two values or the renderer draws two lines. `current()` is evaluated
|
|
438
|
+
when the menu is built, `action()` runs before the host's own repaint, and `preview()` replaces the
|
|
439
|
+
host's "Preview unavailable" default. `dispose()` removes exactly this provider's rows.
|
|
440
|
+
|
|
441
|
+
### Surface 2: a sidebar section
|
|
442
|
+
|
|
443
|
+
One call adds the card to gentle-shell's fullscreen sidebar **and** its show/hide toggle to
|
|
444
|
+
`/gentle:customize` → `Sections`. The plugin writes content only; it never names a `setting`, a
|
|
445
|
+
value, the `Sections` category or the rail:
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
const dispose = api.registerSection({
|
|
449
|
+
id: "loon.music", // unique and stable: ordering, replacement, the toggle
|
|
450
|
+
title: "MUSIC", // card heading, drawn by the host's own frame
|
|
451
|
+
rowLabel: "music", // optional: what the Sections row reads (derived from title when absent)
|
|
452
|
+
meta: () => "1 playing", // optional right-aligned text
|
|
453
|
+
lines: () => ["track Blue in Green", "album Kind of Blue"], // per frame, live
|
|
454
|
+
defaultVisible: true, // absent means true
|
|
455
|
+
order: 10, // optional integer
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
The bridge generates the `Sections` rows for it: two consecutive rows sharing one label,
|
|
460
|
+
values `shown` / `hidden`, with a live `current()` and a persist-first `action()`. The label — not
|
|
461
|
+
the id — is the line's name: `music ● shown ○ hidden`, next to the host's own
|
|
462
|
+
`stats ● shown ○ hidden`. A hidden section is not rendered and its `lines()`/`meta()` are not
|
|
463
|
+
called. A throw loses only that section's body and is reported by `/bridge-status`. `dispose()`
|
|
464
|
+
removes the card and the toggle together, leaving every other section untouched.
|
|
465
|
+
|
|
466
|
+
### Surface 3: the optional namespaced store
|
|
467
|
+
|
|
468
|
+
`createStore(pluginId)` is optional. A plugin may keep its own file instead; the bridge never reads
|
|
469
|
+
or writes that file.
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
const store = api.createStore("loon");
|
|
473
|
+
store.set("bannerSource", "activity");
|
|
474
|
+
const value = store.get("bannerSource", "activity"); // caller's fallback when absent or unreadable
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The document is `{ "schemaVersion": 1, "values": { … } }` at
|
|
478
|
+
`<gentle config home>/gentle-shell-bridge/<pluginId>.json`. `values` is an envelope, so a plugin
|
|
479
|
+
key cannot collide with `schemaVersion`. Writes are atomic: a temporary file in the same directory,
|
|
480
|
+
then a rename over the target. `pluginId` is rejected before any filesystem access unless it is one
|
|
481
|
+
non-empty path segment. A malformed file or an unknown `schemaVersion` reads as empty and is
|
|
482
|
+
reported by `/bridge-status`; it is not repaired or deleted, and a later `set` writes a valid
|
|
483
|
+
document. Details and the rejected alternatives: §10 and [state.md](state.md).
|
|
484
|
+
|
|
485
|
+
### When the API is absent: the plugin decides
|
|
486
|
+
|
|
487
|
+
| What the handler sees | What it means | What the plugin may do |
|
|
488
|
+
| --- | --- | --- |
|
|
489
|
+
| `bridgeApi() === undefined`, or the direct read is `undefined`, **and no announcement arrived** | no bridge has published in this process (yet) | notify and continue, wait for `gentle-shell-bridge:ready`, or fail loudly — say which |
|
|
490
|
+
| the object fails the gate | another library's payload, another major, or a partial object | same as absent: never call it |
|
|
491
|
+
| a compatible object | the bridge is installed, with every surface above | register, then forget about load order |
|
|
492
|
+
|
|
493
|
+
Absent is still absent: `bridgeApi()` describes the slot as it is right now. What changed is the
|
|
494
|
+
conclusion a plugin draws from it — "not installed" is only honest when no announcement can arrive
|
|
495
|
+
(a Pi without `pi.events`, or a plugin that chose not to listen). The bridge cannot tell a plugin
|
|
496
|
+
what to do when it is absent, and does not try: notify-and-continue, a local fallback and a loud
|
|
497
|
+
failure are all legitimate, and only the plugin knows which one fits.
|
|
498
|
+
|
|
499
|
+
### The compatibility cliff, on purpose
|
|
500
|
+
|
|
501
|
+
An older bridge publishes nothing at this key, so a newer plugin sees **absent** instead of being
|
|
502
|
+
deferred silently. A newer bridge and an older plugin meet the same way when the plugin's gate
|
|
503
|
+
demands a member the old object does not have. That is the intended answer: a plugin can always
|
|
504
|
+
tell the user it found no compatible bridge, and no registration can sit invisible in a holding
|
|
505
|
+
area waiting for a version that will never arrive.
|
|
506
|
+
|
|
507
|
+
## 10. Persistence
|
|
508
|
+
|
|
509
|
+
The bridge composes rows; it does not own preferences. A plugin reads and writes its own
|
|
510
|
+
state. `createStore(pluginId)` is an **optional** helper for a namespaced file with atomic
|
|
511
|
+
writes and a schema version; a plugin may keep its own file instead, and the bridge never
|
|
512
|
+
reads or writes that file.
|
|
513
|
+
|
|
514
|
+
The document is `{ "schemaVersion": 1, "values": { … } }`. `values` is an envelope, so a
|
|
515
|
+
plugin key cannot collide with `schemaVersion`. The file lives at
|
|
516
|
+
`<bridge config home>/<pluginId>.json`, where `<bridge config home>` is `<gentle config
|
|
517
|
+
home>/gentle-shell-bridge`; `pluginId` is rejected before it reaches the filesystem unless
|
|
518
|
+
it is one non-empty path segment. A malformed file, or one whose `schemaVersion` is not `1`,
|
|
519
|
+
reads as empty and is reported by `/bridge-status`; the file is not repaired or deleted, and
|
|
520
|
+
a later `set` writes a valid document that carries over existing `values` entries while
|
|
521
|
+
dropping foreign top-level keys. Rationale and rejected alternatives: [state.md](state.md).
|
|
522
|
+
|
|
523
|
+
## 11. Sidebar sections
|
|
524
|
+
|
|
525
|
+
One call adds a card to gentle-shell's fullscreen sidebar **and** its show/hide toggle to
|
|
526
|
+
`/gentle:customize` → `Sections`. The plugin writes content; it never writes a `setting`, a
|
|
527
|
+
`value`, a `current()`, an `action()`, a `preview()`, never names the `Sections` category and
|
|
528
|
+
never touches the rail.
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import { registerSection } from "gentle-shell-bridge";
|
|
532
|
+
|
|
533
|
+
const dispose: () => void = registerSection({
|
|
534
|
+
id: "loon.music", // unique and stable: ordering, replacement, the toggle
|
|
535
|
+
title: "MUSIC", // card heading, drawn like AGENTS / CHANGES / STATS
|
|
536
|
+
rowLabel: "music", // optional: the text the Sections row reads
|
|
537
|
+
meta: () => "1 playing", // optional right-aligned count; live, read per frame
|
|
538
|
+
lines: (ctx) => [ // body, evaluated per frame
|
|
539
|
+
"track Blue in Green", // a plain string: drawn byte for byte, as before
|
|
540
|
+
{ // or a structured row, drawn like a native one
|
|
541
|
+
glyph: "▸",
|
|
542
|
+
label: "album",
|
|
543
|
+
meta: "Kind of Blue",
|
|
544
|
+
tone: "muted", // text | muted | dim | accent | success | error | warning
|
|
545
|
+
onClick: () => play(), // only a row with onClick is clickable
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
defaultVisible: true, // absent means true
|
|
549
|
+
order: 10, // optional integer
|
|
550
|
+
});
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
- `id` — registering an existing id **replaces** the previous section in place; a second
|
|
554
|
+
card never appears. It is the toggle's identity (replacement, disposer and the persisted
|
|
555
|
+
visibility key), and it is **internal**: it never appears in a row.
|
|
556
|
+
- `title` — the heading the host's own card frame draws. The bridge does not draw a box.
|
|
557
|
+
- `rowLabel` — optional: what the `Sections` row reads, e.g. `music`. When absent the bridge
|
|
558
|
+
derives one from `title`: lowercase words joined by spaces, dots and punctuation dropped, no
|
|
559
|
+
prefix, clamped to three words (`"BASH CARD"` → `"bash card"`, `"usageCost"` → `"usage cost"`,
|
|
560
|
+
`"MUSIC PLAYER NOW PLAYING"` → `"music player now"`). Two sections whose labels would read the
|
|
561
|
+
same are told apart deterministically (`music`, `music 2`, …) and `/bridge-status` says which one
|
|
562
|
+
moved. A `rowLabel` that breaks the row-name shape (one to three plain words, no arrow, colon,
|
|
563
|
+
dot, pipe, slash or bracket — see §4) is rejected: the section is not registered, and the
|
|
564
|
+
offending string is reported under `row rejections`. Nothing is rewritten.
|
|
565
|
+
- `meta(ctx)` — optional right-aligned text. Read per frame, so a value that changes is drawn on the
|
|
566
|
+
next frame without re-registering. A throw drops that section's body (see below).
|
|
567
|
+
- `lines(ctx)` — the body. Evaluated **per frame**, so live state is honest with no redraw API. A row
|
|
568
|
+
is a plain string or a structured row (see below).
|
|
569
|
+
- `defaultVisible` — the fallback until the user toggles the section.
|
|
570
|
+
- `order` — sections **with** an `order` render first, ascending; sections without one follow
|
|
571
|
+
in registration order. Ties keep registration order. The rule is computed from an explicit
|
|
572
|
+
sort key, never from map iteration.
|
|
573
|
+
|
|
574
|
+
`ctx` carries the same shape as `RowContext`: the resolved gentle-shell config home and, now that Pi
|
|
575
|
+
hands the bridge a theme (`ctx.ui.setWidget(key, (tui, theme) => …)`), the host theme — so a section
|
|
576
|
+
can paint with the same roles the rest of the shell uses. Before the widget factory runs, `theme` is
|
|
577
|
+
`undefined` and the card is drawn uncoloured; `/bridge-status` says which.
|
|
578
|
+
|
|
579
|
+
### Body rows
|
|
580
|
+
|
|
581
|
+
A row is a plain **string** or a structured **row object**:
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
{ label: string; meta?: string; glyph?: string; tone?: Tone; onClick?: () => void }
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
- a plain string is drawn **byte for byte**: the author's characters, clipped to the width, no
|
|
588
|
+
indent, no padding, no styling. Every section written before structured rows existed looks
|
|
589
|
+
exactly the same;
|
|
590
|
+
- a structured row is drawn the way the host draws its own `AGENTS` rows: indent, `glyph label` on
|
|
591
|
+
the left, the right-aligned dimmer `meta`, and the parts painted with the host's roles;
|
|
592
|
+
- `tone` names one of the host's own roles — `text`, `muted`, `dim`, `accent`, `success`, `error`,
|
|
593
|
+
`warning` — and never a colour or an SGR code. Anything else is **rejected**;
|
|
594
|
+
- `onClick` is what makes a row clickable. A row without it is **inert**: it never highlights under
|
|
595
|
+
the pointer and never answers a click;
|
|
596
|
+
- `meta` is read at render time, so the same registration can show a value that moves (a mode, a
|
|
597
|
+
count, an elapsed time) with no re-registration.
|
|
598
|
+
|
|
599
|
+
The bridge maps physical rows to row identity and answers the host's own pointer dispatch; clicking
|
|
600
|
+
an actionable row runs exactly one `onClick`, once per click. A throwing `onClick` is recorded (phase
|
|
601
|
+
`onClick`) and never escapes into the host.
|
|
602
|
+
|
|
603
|
+
### Rendering
|
|
604
|
+
|
|
605
|
+
A section is drawn by the host's own card code: the bridge contributes the part's header and rows and
|
|
606
|
+
the card groups over them, and the layout calls `sectionCardRows` on them. The HEADER is the host's
|
|
607
|
+
header — caret from the section's own collapse state, uppercase title, right-aligned `meta`, the
|
|
608
|
+
host's hover accent.
|
|
609
|
+
|
|
610
|
+
Where those two builders come from is reported, not assumed. The host's own
|
|
611
|
+
`renderPanelHeader`/`renderPanelRow` live in gentle-shell's `lib/shell-panel.ts`, which this package
|
|
612
|
+
cannot import (Pi loads each extension in its own module graph). So the bridge **looks** for them on
|
|
613
|
+
every object it is handed — the rail part, the Pi theme, the terminal's sidebar state — and:
|
|
614
|
+
|
|
615
|
+
- when it finds them, it draws with them and `/bridge-status` reports `section frame: host`;
|
|
616
|
+
- otherwise it draws with a byte-identical port of the same algorithm (proved against the host's
|
|
617
|
+
code transcribed into `test/section-panel.test.ts`) and reports `section frame: fallback` plus the
|
|
618
|
+
gap, naming exactly what was looked for.
|
|
619
|
+
|
|
620
|
+
## Width discipline on the rail (N17, N18)
|
|
621
|
+
|
|
622
|
+
The host decides the rail's width from the layout, never from a part's output: the rail is the second
|
|
623
|
+
entry of its fullscreen `hstack` with `basis: RAIL_WIDTH, grow: 0, shrink: 0`
|
|
624
|
+
(`lib/shell-sidebar-layout.ts:493-496`), so pi-tui allocates those 50 columns without measuring the
|
|
625
|
+
child, and the conversation gets `columns - 53`. One decision IS made from a part's rows:
|
|
626
|
+
|
|
627
|
+
```text
|
|
628
|
+
const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
|
|
629
|
+
```
|
|
630
|
+
(`lib/shell-sidebar-layout.ts:410`) — a single rail row wider than the content width drops the whole
|
|
631
|
+
rail, and the conversation then takes every column: the layout's widths changed because of what a
|
|
632
|
+
part returned.
|
|
633
|
+
|
|
634
|
+
**N17 — a contributed part never asks for more room than it was given.** Every row the bridge
|
|
635
|
+
contributes is at most the width the host handed the part, measured the way the host measures it:
|
|
636
|
+
`visibleWidth` (`pi-tui/dist/utils.js:212-268`) strips ANSI, expands a TAB to **three** columns
|
|
637
|
+
(`graphemeWidth`, `:148-155`) and charges each grapheme cluster as a whole (`:255-258`) — an emoji
|
|
638
|
+
cluster two columns, a cluster of marks none. `src/card-style-transform.ts`'s `hostColumns` ports
|
|
639
|
+
that model; `src/section-panel.ts` fits and pads with it, and a plain string row is clipped with it
|
|
640
|
+
instead of by UTF-16 length. Padding is counted the same way, so the pad never makes the host believe
|
|
641
|
+
the column wants to be wider.
|
|
642
|
+
|
|
643
|
+
**N18 — the host's own rule, matched.** The host's parts PAD: `renderPanelRow(width, left, right,
|
|
644
|
+
indent)` is `fitPanelLine(" " + left, right, width)`, and `fitPanelLine` pads `left`/`right` to
|
|
645
|
+
`width` (`lib/shell-panel.ts:31-40,78-80`). The bridge therefore pads identically — and to the
|
|
646
|
+
HOST-measured width, which is the whole of N17. A plain string row stays unpadded, byte for byte
|
|
647
|
+
(`§11`, N10).
|
|
648
|
+
|
|
649
|
+
**N19 — proved by a test, not by prose.** `test/rail-width.test.ts` models the host's rail path
|
|
650
|
+
(the fixed `hstack` basis, the one-column padding at `:414`, the `active` gate) and asserts that a
|
|
651
|
+
contributed section leaves the main column where the host put it and that no contributed row exceeds
|
|
652
|
+
the given width under the host's own measure — a CJK body row, a row containing a TAB, and an emoji
|
|
653
|
+
cluster included.
|
|
654
|
+
|
|
655
|
+
**N20 — visible without a screenshot.** `/bridge-status` prints `section width: <N> columns given,
|
|
656
|
+
widest row <M>` after the section frame block, from the last render's own record
|
|
657
|
+
(`getSectionFrameWidth()`); a session whose rail never rendered a section prints no such line.
|
|
658
|
+
|
|
659
|
+
A plain string row is clipped to the width the host passes and the body is capped at 8 rows with a
|
|
660
|
+
visible `… +N more` marker, measured in the host's columns. A structured row is fitted by the panel
|
|
661
|
+
builder, which measures **columns** the same way. The bridge never returns a row wider than that
|
|
662
|
+
number, so a wide CJK glyph, an emoji or a TAB can no longer slip past the clip.
|
|
663
|
+
|
|
664
|
+
**Sections render after the native ones. Interleaving is impossible**: the host's status
|
|
665
|
+
sections (`CHANGES`, `STATS`, `INTEGRATIONS`) are built in a closure the bridge cannot reach
|
|
666
|
+
(`lib/shell-bar.ts:247,251-316`), and the rail paints a hard-coded key order
|
|
667
|
+
(`lib/shell-sidebar-layout.ts:344`). The bridge therefore appends to the last painted part
|
|
668
|
+
(`footer`), so a section always follows every native card.
|
|
669
|
+
|
|
670
|
+
### Collapsing
|
|
671
|
+
|
|
672
|
+
The header is the section's collapse control, as it is for the host's own cards: clicking it folds
|
|
673
|
+
the card. A collapsed section is its header alone — the body is **not built**, not built and hidden,
|
|
674
|
+
so `lines()` is not called while it is folded. The folded state is bridge-owned store state (the
|
|
675
|
+
plugin writes no flag and no setting for it), and a click persists it and asks for the frame in the
|
|
676
|
+
same call.
|
|
677
|
+
|
|
678
|
+
### Live updates and the digest
|
|
679
|
+
|
|
680
|
+
`meta()` and `lines()` are read per frame, and the bridge folds what they produced into the rail
|
|
681
|
+
part's `digest`. The host's layout re-renders a part only when that value moves
|
|
682
|
+
(`lib/shell-sidebar-layout.ts:114-119`), so a row field that changed — a mode, a count, a hovered row
|
|
683
|
+
— reaches the screen with no re-registration and no timer. The host's own digest stays part of the
|
|
684
|
+
value; when nothing contributes rows, the host's answer is passed through unchanged.
|
|
685
|
+
|
|
686
|
+
### Hidden means not built
|
|
687
|
+
|
|
688
|
+
A hidden section is not rendered **and** its `lines()`/`meta()` are not called. Hiding is a
|
|
689
|
+
user choice, not a request to compute something invisible.
|
|
690
|
+
|
|
691
|
+
### Failure isolation
|
|
692
|
+
|
|
693
|
+
A throwing `lines()` or `meta()`, or a return that is not the documented shape, drops that
|
|
694
|
+
section's body, records `{ sectionId, phase, message }` for `/bridge-status`, and leaves every
|
|
695
|
+
native card and every other section rendering. A non-array, a sparse array, or a row that is not a
|
|
696
|
+
string and has no string `label` is a failure, never coerced; so is a `tone` outside the seven host
|
|
697
|
+
roles, a non-string `meta`/`glyph`, and an `onClick` that is not a function. A throwing `onClick` is
|
|
698
|
+
recorded with phase `onClick`.
|
|
699
|
+
|
|
700
|
+
### The automatic `Sections` rows
|
|
701
|
+
|
|
702
|
+
The host's own `Sections` rows are bare labels: `changes`, `rdd`, `agents`, `todo`,
|
|
703
|
+
`usageCost`, `modelDetails`, `contextWarning`, `stats`. The bridge's rows read the same way — a
|
|
704
|
+
label the user reads, never an id and never a prefix:
|
|
705
|
+
|
|
706
|
+
```text
|
|
707
|
+
stats ● shown ○ hidden ← the host's own row
|
|
708
|
+
music ● shown ○ hidden ← a plugin's section (id "loon.music", title "MUSIC")
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
For every registered section the bridge emits **two consecutive rows** in category `Sections`,
|
|
712
|
+
sharing one label as their `choice.setting` (the host prints the setting as the line's name) with
|
|
713
|
+
values `shown` and `hidden`:
|
|
714
|
+
|
|
715
|
+
- the label is the author's `rowLabel`, or `title` made readable (lowercase, words joined, no dots,
|
|
716
|
+
no prefix, clamped to three words); the same string is the row `label` and the line's `setting`.
|
|
717
|
+
An author's `rowLabel` must satisfy the row-name shape (§4): one that does not is rejected, the
|
|
718
|
+
section is never registered, and the string and the rule appear under `row rejections`;
|
|
719
|
+
- `current()` reads the persisted value live, so the `●` never lies;
|
|
720
|
+
- `action()` persists **first** and returns; the host's own repaint re-reads `current()`;
|
|
721
|
+
- `preview()` returns a literal depiction of the card in that state, never the host's
|
|
722
|
+
"Preview unavailable" default.
|
|
723
|
+
|
|
724
|
+
**The id is internal, the label is public.** `id` keys the registry (replacement and ordering), the
|
|
725
|
+
`Sections`-row disposer and `bridge-sections.json`; it is never rendered. The label is the only
|
|
726
|
+
thing a user sees.
|
|
727
|
+
|
|
728
|
+
**Two sections never collide.** If two labels would read the same, the bridge disambiguates in
|
|
729
|
+
render order: the first keeps the label, the next gets the lowest free suffix (`music`, `music 2`,
|
|
730
|
+
`music 3`, …), never the raw id. The assignment is recomputed per menu build from the live
|
|
731
|
+
registry, so it is deterministic for a given set of registrations. `/bridge-status` reports the
|
|
732
|
+
label in force for each section and names the collision, so the menu and the report can never
|
|
733
|
+
disagree. A title with no letters or digits at all derives to `section` (the fallback word)
|
|
734
|
+
rather than an empty or id-shaped label.
|
|
735
|
+
|
|
736
|
+
Rows are built per menu build from the live registry: a section registered later appears
|
|
737
|
+
without re-registering anything, and a disposed section's rows disappear with it.
|
|
738
|
+
|
|
739
|
+
### Persistence: the same experience, a different file
|
|
740
|
+
|
|
741
|
+
Visibility is **bridge-owned state**, not plugin state — the bridge added the toggle, so a
|
|
742
|
+
plugin needs no store of its own. It is persisted through the existing `createStore` helper in
|
|
743
|
+
one namespaced file:
|
|
744
|
+
|
|
745
|
+
`<gentle config home>/gentle-shell-bridge/bridge-sections.json`
|
|
746
|
+
|
|
747
|
+
```json
|
|
748
|
+
{
|
|
749
|
+
"schemaVersion": 1,
|
|
750
|
+
"values": {
|
|
751
|
+
"sections": { "loon.music": false },
|
|
752
|
+
"collapsed": { "loon.music": true }
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
`sections` maps a section id to `true` (shown) / `false` (hidden); a missing id falls back to
|
|
758
|
+
`defaultVisible`. `collapsed` maps a section id to `true` (folded) / `false` (open); a missing id is
|
|
759
|
+
OPEN, the state a card a user has never folded starts in. A value that is not a boolean is ignored
|
|
760
|
+
rather than coerced. Both maps are bridge-owned: the plugin writes neither a flag nor a setting.
|
|
761
|
+
|
|
762
|
+
Why not `visual-customization.json`: the host validates that file against a closed
|
|
763
|
+
`VISUAL_SECTION_KEYS` list (`lib/visual-customization-policy.ts:82`, `keysMatch` ~:112-130) and
|
|
764
|
+
rejects unknown keys, so a third-party section key cannot live there. The user experience is
|
|
765
|
+
identical to a native section toggle; the file deliberately is not. The blast radius is the
|
|
766
|
+
same per-plugin decision recorded in [state.md](state.md).
|
|
767
|
+
|
|
768
|
+
## 12. Card styles
|
|
769
|
+
|
|
770
|
+
One call adds a card style to gentle-shell's conversation cards — a third option on the same
|
|
771
|
+
`Card style` line the host already draws, with no change to the host.
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
import { registerCardStyle } from "gentle-shell-bridge";
|
|
775
|
+
|
|
776
|
+
const dispose: () => void = registerCardStyle({
|
|
777
|
+
id: "loon.cute", // identity: replacement and ordering key, and the stored value
|
|
778
|
+
label: "cute", // what the user reads; unique among the line's values
|
|
779
|
+
rowLabel: "cute", // optional: what this style's row reads (derived from label when absent)
|
|
780
|
+
order: 10, // optional: ordered styles first, ascending, then registration order
|
|
781
|
+
frame: { top, line, bottom }, // the three functions below; nothing else is called
|
|
782
|
+
preview: () => ({ title: "Cute", sample: "○ cute ╭─ welcome ─╮" }), // optional, literal
|
|
783
|
+
});
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
The id never appears in a row: the host's line reads `Card style ○ neon ● float ○ cute`, and the
|
|
787
|
+
style's own row is labelled `cute` (or the author's `rowLabel`), exactly like a host row's visible
|
|
788
|
+
text. `label` stays the line's value — the text the host renders and the uniqueness rule applies to
|
|
789
|
+
— while `rowLabel` is what the menu row reads when it is not a setting line.
|
|
790
|
+
|
|
791
|
+
### The frame contract
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
top(ctx: { width: number; title?: string; hint?: string }): string;
|
|
795
|
+
line(ctx: { text: string; width: number; first: boolean }): string;
|
|
796
|
+
bottom(ctx: { width: number }): string;
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
`width` is the width the host rendered at. `title` is the heading the host printed, without its
|
|
800
|
+
glyph and without the right-aligned hint; `hint` is that hint when its style runs prove it is one
|
|
801
|
+
(a subtitle is never passed as a hint, and when the split is not provable the whole remainder
|
|
802
|
+
arrives as `title` with `hint` undefined). `line` receives one body row at a time, `first` true for
|
|
803
|
+
the card's first row. The bridge extracts all of it and passes it in; a style parses nothing. A row
|
|
804
|
+
a style returns wider than `width` is clipped before it reaches the screen.
|
|
805
|
+
|
|
806
|
+
### The host's own line is extended, never duplicated
|
|
807
|
+
|
|
808
|
+
At capture the bridge locates the host's `Card style` rows (category `Cards`, that `setting`),
|
|
809
|
+
splices them out and puts them back at the same index, immediately followed by one row per
|
|
810
|
+
registered style, all sharing the host's `setting`. The host coalesces consecutive rows that share a
|
|
811
|
+
setting, so the user sees ONE line in the host's own position:
|
|
812
|
+
|
|
813
|
+
```text
|
|
814
|
+
Card style ○ neon ● float ○ cute
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
The host's own rows keep the host's `current()` and `action()`, wrapped in exactly two ways:
|
|
818
|
+
`current()` reports `false` while a registered style is selected (one bullet at a time), and
|
|
819
|
+
`action()` also returns the bridge to native rendering (below). The bridge never reimplements
|
|
820
|
+
`neon`/`float` and never writes the host's `card-style.json`.
|
|
821
|
+
|
|
822
|
+
### Which surfaces a style reaches
|
|
823
|
+
|
|
824
|
+
A style is one global choice, so it paints **both** surfaces the host frames cards on:
|
|
825
|
+
|
|
826
|
+
- **the conversation** — the chat component, whose `render(width)` the bridge wraps;
|
|
827
|
+
- **the rail** — the fullscreen sidebar, whose section cards the host frames itself
|
|
828
|
+
(`lib/shell-sidebar-layout.ts:36-47`) and whose framed rows the bridge repaints through the same
|
|
829
|
+
`transformCardLines` the conversation uses. Host sections (`CHANGES`, `AGENTS`, …) and the
|
|
830
|
+
sections a plugin contributed are restyled alike, because the rail frames them with one function.
|
|
831
|
+
|
|
832
|
+
`native` is the pass-through on both: the host's own frames reach the screen as the same array
|
|
833
|
+
instance the host produced. A plugin registers nothing extra for the rail — the style it already
|
|
834
|
+
declared is the whole change — and `/bridge-status` reports each
|
|
835
|
+
surface separately (`card style surface conversation: …`, `card style surface rail: …`) so "the
|
|
836
|
+
conversation changed but the sidebar did not" is answerable.
|
|
837
|
+
|
|
838
|
+
The rail is restyled only where the host already drew a `float` frame: a `neon` rail, a panel
|
|
839
|
+
surface, or any row the bridge cannot prove is a card passes through untouched.
|
|
840
|
+
|
|
841
|
+
### A selection repaints, synchronously
|
|
842
|
+
|
|
843
|
+
Pressing a value writes the selection and then repaints, before the action returns: the host's own
|
|
844
|
+
`invalidateSidebar` step (its terminal-side cache revision) and `requestRender()`. A press that
|
|
845
|
+
selects the value already stored requests no frame. The same rule covers a section's shown/hidden
|
|
846
|
+
toggle, which repaints the rail. When no `tui` has been captured, or it has no `requestRender`, the
|
|
847
|
+
write still lands and `/bridge-status` reports `last repaint: … (unavailable: …)` — a write that
|
|
848
|
+
reached no surface is never silent.
|
|
849
|
+
|
|
850
|
+
### The way back to host rendering
|
|
851
|
+
|
|
852
|
+
Pressing a host value (`neon`/`float`) **is** the way back: the host's own action runs, and the
|
|
853
|
+
bridge clears its selection to `native` in a `finally`, so even a throwing setter cannot leave the
|
|
854
|
+
session trapped inside a bridge style. The host's values are native rendering.
|
|
855
|
+
|
|
856
|
+
### Fallback mode
|
|
857
|
+
|
|
858
|
+
If the host's line cannot be located (a renamed setting, a menu without `Cards`, no capture at
|
|
859
|
+
all), the bridge contributes its own line instead — `Bridge card style`, with `native` plus every
|
|
860
|
+
registered style. `native` means "the bridge leaves the conversation alone". `/bridge-status`
|
|
861
|
+
reports which mode is in force; the values are never dropped to fit a narrow terminal, and the
|
|
862
|
+
bridge does not control that width.
|
|
863
|
+
|
|
864
|
+
### The selection
|
|
865
|
+
|
|
866
|
+
The selection is the bridge's state, not the plugin's:
|
|
867
|
+
`<gentle config home>/gentle-shell-bridge/bridge-card-style.json`.
|
|
868
|
+
|
|
869
|
+
```json
|
|
870
|
+
{ "schemaVersion": 1, "values": { "cardStyle": "loon.cute" } }
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
`cardStyle` is a style id or `native`. A missing file, an unreadable one, an unknown
|
|
874
|
+
`schemaVersion` or a non-string value reads as `native`. A stored id whose style was disposed
|
|
875
|
+
resolves to **missing**, which behaves exactly like `native` — no chrome is drawn for a style that
|
|
876
|
+
no longer exists — and `/bridge-status` says `selection: … (missing: not registered)`. That is the
|
|
877
|
+
whole degrade rule: choosing a style is opt-in, and losing the plugin that owns it can never leave a
|
|
878
|
+
conversation broken.
|
|
879
|
+
|
|
880
|
+
### What a registration must satisfy
|
|
881
|
+
|
|
882
|
+
- `id`: a non-empty string, not `__proto__`/`constructor`/`prototype`, no NUL byte, and not a value
|
|
883
|
+
the line already shows (`neon`, `float`, `native`).
|
|
884
|
+
- `label`: a non-empty string, unique — case-sensitively, as the host displays it — among the
|
|
885
|
+
line's values (another registered style's label or the host's own). Two values reading the same
|
|
886
|
+
cannot be told apart.
|
|
887
|
+
- `rowLabel`: optional; the text this style's row reads, used verbatim. Absent, it is derived from
|
|
888
|
+
`label` (lowercase words, no dots, no prefix, at most three words). The same row-name shape as
|
|
889
|
+
every other row applies (§4): one that breaks it is rejected — the style is not registered, and
|
|
890
|
+
the string and the rule are reported under `row rejections`. An empty one is rejected too.
|
|
891
|
+
The `id` is never a row label.
|
|
892
|
+
- `frame.top`, `frame.line`, `frame.bottom`: functions. Any failure throws **before** anything is
|
|
893
|
+
stored, like the row and section id rules.
|
|
894
|
+
- Registering an existing `id` replaces that style in place; `dispose()` removes exactly the one
|
|
895
|
+
registration it created, and calling it twice is a no-op.
|
|
896
|
+
|
|
897
|
+
### What cannot be promised
|
|
898
|
+
|
|
899
|
+
- A later post-processor that rewrites the conversation — a component that re-frames the rows and
|
|
900
|
+
strips box chrome — can erase this frame. The bridge cannot prevent it, and
|
|
901
|
+
`/bridge-status` says so instead of pretending otherwise, naming the mechanism and no product.
|
|
902
|
+
- The repaint depends on the host's own `float` grammar (see
|
|
903
|
+
[architecture.md](architecture.md) §14): a card drawn some other way is left exactly as it is.
|
|
904
|
+
- A style never breaks the conversation: any throw inside detection or inside a style makes that
|
|
905
|
+
frame a pass-through and is reported.
|
|
906
|
+
|
|
907
|
+
## 13. Transcript transforms
|
|
908
|
+
|
|
909
|
+
One call adds a function that sees the conversation's rows on **every frame** and returns the rows to
|
|
910
|
+
draw. It is the layer a rendering choice needs, because `pi.registerToolRenderer` resolves once, when
|
|
911
|
+
a tool row is BUILT: a choice made there can only reach the NEXT row, while a transform runs inside
|
|
912
|
+
the wrapper the bridge already installs on the conversation component
|
|
913
|
+
([architecture.md](architecture.md) §15).
|
|
914
|
+
|
|
915
|
+
```ts
|
|
916
|
+
import { registerTransform } from "gentle-shell-bridge";
|
|
917
|
+
|
|
918
|
+
const dispose: () => void = registerTransform({
|
|
919
|
+
id: "loon.gutter", // unique and stable: ordering, replacement, the /bridge-status name
|
|
920
|
+
order: 10, // optional: ordered transforms run first, ascending
|
|
921
|
+
transform: (lines, ctx) => {
|
|
922
|
+
const rows = lines.filter((line) => line.trim() !== "");
|
|
923
|
+
ctx.report?.({ // optional telemetry: counts only, never a line of the frame
|
|
924
|
+
candidates: lines.length,
|
|
925
|
+
recognised: rows.length,
|
|
926
|
+
rewritten: lines.length - rows.length,
|
|
927
|
+
});
|
|
928
|
+
return rows;
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
### What the transform receives
|
|
934
|
+
|
|
935
|
+
`lines` is the frame's rows: the host's own output, after the card-style repaint when a style is
|
|
936
|
+
selected (so a transform sees what the user would have seen), and after every transform that ran
|
|
937
|
+
before it — the pipeline is ordered, and each transform receives the previous one's output.
|
|
938
|
+
`ctx.width` is the width the host rendered at.
|
|
939
|
+
|
|
940
|
+
A transform receives LINES and nothing else: no tool name, no arguments, no result object. A plugin
|
|
941
|
+
that needs tool metadata uses `pi.registerToolRenderer` for rows yet to be built; a plugin that needs
|
|
942
|
+
a rendering choice to apply to rows ALREADY on screen uses a transform.
|
|
943
|
+
|
|
944
|
+
### What the bridge guarantees
|
|
945
|
+
|
|
946
|
+
- **Order.** Transforms with an `order` run first, ascending, then the rest in registration order —
|
|
947
|
+
the rule sections and card styles already use. The sequence is snapshotted once per frame.
|
|
948
|
+
- **Width.** Whatever a transform returns is clipped to `ctx.width` with the same `clipToWidth` the
|
|
949
|
+
card styles use, so it cannot make the frame wider than the terminal. The measure and the cut are
|
|
950
|
+
the host's: a TAB is three columns, a grapheme cluster is charged as a whole and never split, and an
|
|
951
|
+
OSC-8 hyperlink contributes its text, not its URL. A row that already fits under that measure is
|
|
952
|
+
returned byte-identical, escapes included — a transform that did not touch a row must not cost the
|
|
953
|
+
host a byte. When a row IS cut, an OSC-8 link left open is closed and an active SGR is reset, so a
|
|
954
|
+
shortened row cannot leak a link or a paint into the rows after it; nothing is padded to fit.
|
|
955
|
+
- **Isolation.** A transform that throws, or that returns something which is not an array of
|
|
956
|
+
strings, loses only its own contribution for that frame: the lines in force are kept, the failure is
|
|
957
|
+
recorded with its id and message, and the remaining transforms still run. Nothing a transform does
|
|
958
|
+
can throw into Pi's render.
|
|
959
|
+
- **Identity.** With no transform registered the wrapper returns the host's own array instance, and a
|
|
960
|
+
transform that returns its input unchanged keeps it: a frame nothing changed is the same array.
|
|
961
|
+
- **Telemetry.** `ctx.report({ candidates, recognised, rewritten, shapes })` records what the
|
|
962
|
+
transform's own grammar did with the frame; `/bridge-status` prints it next to the outcome (§ below).
|
|
963
|
+
|
|
964
|
+
### Frame telemetry: what a transform saw
|
|
965
|
+
|
|
966
|
+
The outcome alone cannot tell "the grammar recognised nothing" from "the frame never reached the
|
|
967
|
+
plugin" — the difference between a working pipeline and one that silently misses. So a transform
|
|
968
|
+
may report, per frame:
|
|
969
|
+
|
|
970
|
+
- `candidates`: the rows it examined as a possible card head;
|
|
971
|
+
- `recognised`: the candidates its grammar accepted;
|
|
972
|
+
- `rewritten`: the cards it actually rewrote on this frame (`0` when a setting leaves them alone);
|
|
973
|
+
- `shapes` (optional): which of its shapes matched, by name, and how many times.
|
|
974
|
+
|
|
975
|
+
A shape name is the transform's own word for a frame it knows; it never carries a line. A plugin
|
|
976
|
+
that reads `float` cards and the host's `neon` outline reports those two names, so a `/bridge-status`
|
|
977
|
+
line names the shape that matched instead of leaving the reader to guess.
|
|
978
|
+
|
|
979
|
+
The bridge adds the count of lines the transform received and stores the whole record as that
|
|
980
|
+
frame's entry — one record per transform, replaced by the next frame, never accumulated.
|
|
981
|
+
|
|
982
|
+
**Counts only.** A frame carries the user's private output, so nothing of it is stored or shown: no
|
|
983
|
+
line, no text, no plugin content. Every number is clamped to a non-negative integer; a shape name is
|
|
984
|
+
kept only when it IS a name — lowercase letters, digits, `_`, `.`, `-`, at most 24 characters — so a
|
|
985
|
+
space or an escape is dropped rather than recorded (never rewritten and shown); and at most 12 shape
|
|
986
|
+
names are kept per frame. A shape name lives in `/bridge-status` beside plugin ids, which is
|
|
987
|
+
diagnostics, never a row (§4).
|
|
988
|
+
|
|
989
|
+
**It cannot cost a frame.** The counts come from the same call that produces the output, with no
|
|
990
|
+
second pass, and recording is guarded: a bridge older than the telemetry has no `report` at all (the
|
|
991
|
+
member is optional — gate with `typeof ctx.report === "function"`), a report of nonsense records
|
|
992
|
+
zeros, and a transform that throws keeps the counts it reached. Telemetry never changes what is
|
|
993
|
+
drawn.
|
|
994
|
+
|
|
995
|
+
### What a registration must satisfy
|
|
996
|
+
|
|
997
|
+
- `id`: the same rule as every other surface — a non-empty string, not `__proto__`/`constructor`/
|
|
998
|
+
`prototype`, no NUL byte.
|
|
999
|
+
- `transform`: a function of `(lines, ctx)`. Anything else throws **before** anything is stored.
|
|
1000
|
+
- Registering an existing `id` replaces that transform in place; `dispose()` removes exactly the one
|
|
1001
|
+
registration it created, and calling it twice is a no-op.
|
|
1002
|
+
|
|
1003
|
+
### `/bridge-status`
|
|
1004
|
+
|
|
1005
|
+
The block is emitted only when a transform is registered, so a session that never used the pipeline
|
|
1006
|
+
keeps its report byte for byte:
|
|
1007
|
+
|
|
1008
|
+
```text
|
|
1009
|
+
transforms: 2 registered
|
|
1010
|
+
- loon.bash-cards (order 10): applied, 13 lines, 4 candidates, 1 recognised, 1 rewritten, shapes: float 1
|
|
1011
|
+
- loon.strict (registration order): failed (definitions are missing), 13 lines, 0 candidates, 0 recognised, 0 rewritten
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
`applied` means the frame changed, `skipped` means the transform was called and left the rows as they
|
|
1015
|
+
were, `failed` carries its message, and `not run` means it was registered after the last frame. The
|
|
1016
|
+
outcome is the LAST frame's, not a history: a report is about what the user is looking at now. The
|
|
1017
|
+
counts after it belong to that same frame — lines received, then what the transform reported — and a
|
|
1018
|
+
`not run` transform has none, because no frame has asked it anything yet.
|
|
1019
|
+
|
|
1020
|
+
## 14. Tool designs
|
|
1021
|
+
|
|
1022
|
+
A **design** decides how ONE tool's card is drawn, without owning the frame. It is declared per tool
|
|
1023
|
+
NAME and composed with the host's own card:
|
|
1024
|
+
|
|
1025
|
+
```ts
|
|
1026
|
+
api.registerToolDesign({
|
|
1027
|
+
tool: "bash",
|
|
1028
|
+
id: "loon.bash-heading",
|
|
1029
|
+
label: "bash heading", // the value on the Cards line, not a row name
|
|
1030
|
+
name: "Loon kit", // optional INTEGRATIONS name (see §8)
|
|
1031
|
+
description: "heads the card with bash instead of the host's $ mark",
|
|
1032
|
+
preview: () => ({ title: "Cards · bash heading", sample: "╭─ bash npm test ─╮" }),
|
|
1033
|
+
apply(lines, ctx) { // the host's rows in, the rows to draw out
|
|
1034
|
+
return lines.map((line) => rewriteTheMarkOnly(line));
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
### The convention: lines in, lines out
|
|
1040
|
+
|
|
1041
|
+
`apply(lines, ctx)` receives the rows the HOST's own card produced — ANSI sequences, padding, the
|
|
1042
|
+
right-aligned hint, the frame's rails, everything — and returns the rows to draw. `ctx` is
|
|
1043
|
+
`{ width, tool, theme, part }`: the width the host asked its own component to render at, the tool name
|
|
1044
|
+
the design was declared for, the host's opaque theme object, and which half of the card this pass is
|
|
1045
|
+
drawing (`"call"` for `renderCall`, `"result"` for `renderResult`).
|
|
1046
|
+
|
|
1047
|
+
**`part` is what tells the call from the result.** A card has two components and the host draws both
|
|
1048
|
+
with the same chrome, so the rows alone cannot distinguish a heading from output: under the `float`
|
|
1049
|
+
style a result body row that begins with `$ ` (a script echoing its commands) sits exactly where a
|
|
1050
|
+
heading sits. The bridge wraps the two renderers separately and sets `part` per renderer, so a design
|
|
1051
|
+
that must not touch the result returns it unchanged when `ctx.part === "result"` instead of guessing
|
|
1052
|
+
from drawn text. The field is additive: a design that never reads it behaves exactly as before.
|
|
1053
|
+
|
|
1054
|
+
**The frame is the host's, so the card style is preserved by construction.** With `Card style` on
|
|
1055
|
+
`neon` the card is still neon, with `float` it is still float, because the chrome was drawn before the
|
|
1056
|
+
design saw a byte of it. The bridge never re-frames, re-measures or clips a design's output (§13's
|
|
1057
|
+
width rule is the transform pipeline's); a design keeps the rows it was given exactly as wide as the
|
|
1058
|
+
host made them.
|
|
1059
|
+
|
|
1060
|
+
Two rules follow from that:
|
|
1061
|
+
|
|
1062
|
+
- **Change only what the design is for.** A bash design that heads the card `bash` changes the
|
|
1063
|
+
heading row and nothing else: every other row — every ANSI run, every pad, the hint, the rails —
|
|
1064
|
+
comes back byte-identical. The rows it does change stay the host's rows; they are edited, never
|
|
1065
|
+
rebuilt from a second grammar.
|
|
1066
|
+
- **It is declared by tool NAME, not recognised by drawn text.** The bus already knows which tool's
|
|
1067
|
+
card it is wrapping; a design that scans a frame for the word it expects is the fragility this
|
|
1068
|
+
surface replaces (the host retitled the bash card and every drawn-text grammar went blind).
|
|
1069
|
+
|
|
1070
|
+
### Declared per tool name (S7)
|
|
1071
|
+
|
|
1072
|
+
A design names a tool (`bash`, `write`, `edit`, `mcp__engram__mem_save`, `lsp_navigation`, …). The bus
|
|
1073
|
+
keeps one registry per name, no name is special-cased, and a design declared for a tool that has no
|
|
1074
|
+
card yet applies as soon as Pi builds one: Pi resolves renderers when the card is CONSTRUCTED, and the
|
|
1075
|
+
bridge's one resolver is installed for the whole session.
|
|
1076
|
+
|
|
1077
|
+
### When the bridge is not first in the chain (S10)
|
|
1078
|
+
|
|
1079
|
+
Pi composes tool-renderer resolvers in **extension load order** (`runner.js:550-553`) and exposes no
|
|
1080
|
+
API to reorder them (`loader.js:298-302` only pushes). A resolver loaded before this package decides
|
|
1081
|
+
first, and one that answers WITHOUT calling `next()` hides the bridge's resolver completely. The
|
|
1082
|
+
host's own quiet bash resolver does exactly that while `Card content` is `minimal`
|
|
1083
|
+
(`extensions/quiet-tools.ts:910-914`), which is why a selected design could still leave every card
|
|
1084
|
+
drawing `$ …`.
|
|
1085
|
+
|
|
1086
|
+
The design therefore has a second path: after Pi constructs a card, the bridge adopts it. The
|
|
1087
|
+
conversation component the bridge already wraps for card styles has Pi's `ToolExecutionComponent`s as
|
|
1088
|
+
children; each carries `toolName` and the `toolDefinition` Pi resolved for it. On every conversation
|
|
1089
|
+
render pass, before the host renders its children, the bridge replaces that card's `toolDefinition`
|
|
1090
|
+
with the same wrapper the resolver path uses (so the frame and the live-selection rule are
|
|
1091
|
+
identical), marks the wrapper so the two paths cannot stack, and invalidates the card so its stored
|
|
1092
|
+
renderer re-runs through the design. Cards Pi rebuilds (reload, `/tree`, session switch) are new
|
|
1093
|
+
children and are adopted on the next pass. A card of a tool with no designs is never touched; a card
|
|
1094
|
+
whose `toolDefinition` is missing or refuses the assignment is reported in `/bridge-status` and drawn
|
|
1095
|
+
by the host.
|
|
1096
|
+
|
|
1097
|
+
This does not replace the resolver path: when the bridge IS ahead in the chain its resolver wraps the
|
|
1098
|
+
renderers at construction, and adoption then recognises the marker and adds nothing. `/bridge-status`
|
|
1099
|
+
counts both per tool (`wrapper mounted on N cards`, `adopted N cards`), so the next failure says
|
|
1100
|
+
which path ran.
|
|
1101
|
+
|
|
1102
|
+
### Selection, and the default
|
|
1103
|
+
|
|
1104
|
+
The selected design per tool is a row in `/gentle:customize` → `Cards`: one line per tool, named from
|
|
1105
|
+
the TOOL (never from the plugin or the id) and prefixed with the category's own word, whose values are
|
|
1106
|
+
|
|
1107
|
+
1. `native` — **the host's own card, always first and the DEFAULT**, and
|
|
1108
|
+
2. every design registered for that tool, in registration order, each shown by its `label`.
|
|
1109
|
+
|
|
1110
|
+
**The line name is `Card <tool>`**, the vocabulary the `Cards` category already uses for `Card style`
|
|
1111
|
+
and `Card content`: `Card bash`, `Card lsp navigation`, `Card mem save`. A row name is one to three
|
|
1112
|
+
plain words (`src/row-names.ts`), so the prefix leaves room for at most two tool words; a longer tool
|
|
1113
|
+
name keeps its TAIL (`mcp__engram__mem_save` → `Card mem save`, never `Card mcp engram`), a name that
|
|
1114
|
+
derives to no words reads `Card tool`, and two tools that read the same after that clamp are told
|
|
1115
|
+
apart with an ordinal (`Card mem save`, `Card mem 2`) that keeps the shape rule. The line's name is
|
|
1116
|
+
not persisted and is not identity: the store is keyed by the TOOL name (`bridge-tool-designs` →
|
|
1117
|
+
`selections` → `{ "bash": … }`) and a value is the design's `id`, so renaming the line moves nothing
|
|
1118
|
+
but the text the user reads.
|
|
1119
|
+
|
|
1120
|
+
The stored value is the design's `id`, so renaming a label never loses a user's choice. Changing the
|
|
1121
|
+
value repaints the cards already on screen: the wrapper reads the live selection on every render pass,
|
|
1122
|
+
so a design chosen after a card was drawn changes that same card. A selection naming a design that is
|
|
1123
|
+
no longer registered degrades to the host's own card and is reported as `missing` in
|
|
1124
|
+
`/bridge-status`.
|
|
1125
|
+
|
|
1126
|
+
### What a switch costs (S13)
|
|
1127
|
+
|
|
1128
|
+
That repaint is **one differential frame**: `requestRepaint()` bumps the host's terminal-side cache
|
|
1129
|
+
revision (`invalidateSidebar(tui)`'s one step) and calls `tui.requestRender()` with no argument. It
|
|
1130
|
+
does **not** call `tui.invalidate()` and does **not** pass `force = true`. The wrapper renders the
|
|
1131
|
+
host's component and applies the live selection on top of whatever rows it answers with — including
|
|
1132
|
+
rows the host's own frame cache hands back (`ToolCardRows`, `cards-bash-v2`
|
|
1133
|
+
`extensions/quiet-tools.ts:612-624`, keys its frame on width, the host's style and whether the card
|
|
1134
|
+
is running; the design is not in that key), so no cache has to be dropped for a switch to land.
|
|
1135
|
+
|
|
1136
|
+
Measured on real pi-tui and Pi's `ToolExecutionComponent` on transcripts of 50, 200 and 600 children
|
|
1137
|
+
(half Markdown assistant messages with a code block and a list, half bash cards; median of 5 runs;
|
|
1138
|
+
Node 26; fake terminal that counts the bytes the TUI writes):
|
|
1139
|
+
|
|
1140
|
+
| 600 children, one switch | frame | bytes the TUI wrote |
|
|
1141
|
+
| --- | ---: | ---: |
|
|
1142
|
+
| `requestRender()` — what a switch does now | 21 ms | 0 (the diff finds nothing else) |
|
|
1143
|
+
| `tui.invalidate()` + a frame | 730 ms | 0 (every Markdown message re-parsed) |
|
|
1144
|
+
| `requestRender(true)` — forced | 33 ms | 6.9 MB (whole buffer cleared and rewritten) |
|
|
1145
|
+
| invalidate + force — before S13 | 753 ms | 6.9 MB |
|
|
1146
|
+
|
|
1147
|
+
The invalidate is the cost: it clears every mounted component's frame cache, so 300 Markdown
|
|
1148
|
+
messages re-parse and re-highlight before the frame can be drawn (`tui.js:585-590`). The force is the
|
|
1149
|
+
second: `resetRenderState()` throws away the previous frame, so the TUI clears the screen and
|
|
1150
|
+
scrollback and rewrites every row (`tui.js:658-663`). The host's own `Card style` switch never did
|
|
1151
|
+
either — it writes its value, bumps the sidebar revision and calls `requestRender()` with no
|
|
1152
|
+
argument (`extensions/gentle-shell.ts:3409-3412`) — which is why it never froze.
|
|
1153
|
+
|
|
1154
|
+
The other candidates were measured and NOT acted on, because the numbers refused them: reading the
|
|
1155
|
+
persisted selection costs 3.5 µs per card per frame (0.35 ms at 100 cards, ~1 ms at 300 — steady, and
|
|
1156
|
+
0.1% of the old switch), and adopting a card after construction costs ~0.02 ms and one local
|
|
1157
|
+
invalidate per card, once. See `test/tool-design-cost.test.ts`, which pins the result by COUNTING
|
|
1158
|
+
calls instead of timing them: a switch invalidates no component, re-parses no Markdown row, requests
|
|
1159
|
+
exactly one differential frame, and performs at most one persisted-selection read per card per frame,
|
|
1160
|
+
whatever the number of Markdown rows in the transcript.
|
|
1161
|
+
|
|
1162
|
+
#### What a bridge-owned switch costs (S19)
|
|
1163
|
+
|
|
1164
|
+
`requestSurfaceRepaint()` — the path a bridge-owned row takes (a card style such as the example's
|
|
1165
|
+
`plain`, a section toggle) — used to keep the invalidation and the forced frame. It is now the same two
|
|
1166
|
+
steps as `requestRepaint()`: the host's sidebar revision and one differential frame. Measured with real
|
|
1167
|
+
pi components, with and without `tui.invalidate()` + `requestRender(true)`, the rows are byte-identical
|
|
1168
|
+
on the conversation, on a rail host panel and on a bridge section hidden, collapsed and expanded: the
|
|
1169
|
+
style is repainted by the wrapper on the host's own already-rendered rows on every pass, and a section
|
|
1170
|
+
toggle moves `sectionRailDigest`, which the host's rail memo already keys on.
|
|
1171
|
+
|
|
1172
|
+
Median of 7, 600 children, one switch: `plain` 697 ms before / 420 ms after, a host style 306 / 19 ms,
|
|
1173
|
+
a rail section toggle 99 / 3 ms. The control that keeps the zeros honest: a host card whose frame key
|
|
1174
|
+
omits the style is the only case an invalidation repairs, and it would deserve a LOCAL invalidate on that
|
|
1175
|
+
card, never the tree. `test/surface-repaint-cost.test.ts` bounds it by call counts (zero tree
|
|
1176
|
+
invalidations, zero prose re-parses, zero card invalidations, one differential frame, at 3 and at 200
|
|
1177
|
+
Markdown rows).
|
|
1178
|
+
|
|
1179
|
+
Stated residual: switching TO `plain` at 600 children still costs about 420 ms (the style draws 4 rows
|
|
1180
|
+
where the host's float card draws 7, so the transcript shrinks by ~270 rows and pi rewrites its buffer,
|
|
1181
|
+
and `transformCardLines` re-scans the transcript per pass); switching back costs about 16 ms. The rail
|
|
1182
|
+
model in that measurement reproduces the host's memo key (`lib/shell-sidebar-layout.ts:310-350`); it is
|
|
1183
|
+
a model, not the host's own module.
|
|
1184
|
+
|
|
1185
|
+
### Registration discipline
|
|
1186
|
+
|
|
1187
|
+
Same rules as every other surface: `tool` and `id` are validated before anything is stored; a second
|
|
1188
|
+
registration with the same (`tool`, `id`) replaces in place and is REPORTED (never a silent takeover);
|
|
1189
|
+
two designs of one tool cannot share a visible label; `native` is reserved. A design that throws, or
|
|
1190
|
+
returns something that is not an array of strings, loses only its own card — the host's rows are drawn
|
|
1191
|
+
— and is reported once per (tool, id, message).
|
|
1192
|
+
|
|
1193
|
+
`name` is optional and does the same job as `RowProvider.name` (§2): a single-file extension with no
|
|
1194
|
+
owning `package.json` gets an INTEGRATIONS line only if it names itself, and a plugin that contributes
|
|
1195
|
+
ONLY designs is attributed like any other — its registrations are `accepted`/`rejected` in the
|
|
1196
|
+
report, and its `name` is sticky for the identity (§8).
|
|
1197
|
+
|
|
1198
|
+
The bridge registers exactly ONE `pi.registerToolRenderer` resolver for the whole process and fans it
|
|
1199
|
+
out to these designs. That is why two plugins contributing designs for the same tool do not stack
|
|
1200
|
+
wrappers, and why a plugin must never call `pi.registerToolRenderer` itself for a design.
|
|
1201
|
+
|
|
1202
|
+
## Limits
|
|
1203
|
+
|
|
1204
|
+
- `registerRows`, `registerSection`, `registerCardStyle`, `registerTransform`,
|
|
1205
|
+
`registerToolDesign`, `requestRepaint`, `createStore`, `bridgeApi`, `isBridgeApi`, `onBridgeApi`
|
|
1206
|
+
and the `gentle-shell-bridge:ready` event are the contract; anything a plugin does inside `action()` beyond
|
|
1207
|
+
calling inline helpers is the plugin's own business.
|
|
1208
|
+
- **`Keymaps` is reserved.** A provider registered with `category: "Keymaps"` is refused before
|
|
1209
|
+
anything is stored, with an error naming the category and why (its own key capture and footer; not
|
|
1210
|
+
open to plugins yet); the refusal is a rejected registration in the plugin's INTEGRATIONS line and in
|
|
1211
|
+
`/bridge-status`. A single row whose own `category` is `Keymaps` is dropped and reported the same way,
|
|
1212
|
+
and its siblings still render. The comparison is trimmed and case-insensitive: ` keymaps ` and
|
|
1213
|
+
`KEYMAPS` are the same page; `Keymap` and `Key maps` are ordinary categories. `Profiles` and `Reset`
|
|
1214
|
+
are not reserved: a plugin row there renders as an ordinary line. A refusal of one row is listed under
|
|
1215
|
+
`row rejections` but is not counted in the plugin's INTEGRATIONS line (a build cannot reach it; only the
|
|
1216
|
+
registration refusal does).
|
|
1217
|
+
- **A new category becomes a page.** The host computes its page list once, in the view's constructor
|
|
1218
|
+
(`lib/visual-customize-view.ts:387`), so the rows must be in the array before that `map` runs; see
|
|
1219
|
+
[architecture.md](architecture.md) "A brand-new page". The cost is stated there. The one residual
|
|
1220
|
+
risk: a host that never derives categories from its menu while another array in the same factory is
|
|
1221
|
+
mapped exactly like the host's page derivation would have its rows injected into the wrong array and
|
|
1222
|
+
report `injected`; the real host derives them at `:387`, and with that shape a competing array stays
|
|
1223
|
+
`ambiguous` and nothing is injected.
|
|
1224
|
+
- **Sections join the rail after the native cards, never between them.** The host's status
|
|
1225
|
+
sections are unreachable (a closure), so "interleaved with `CHANGES`" is not offered; the
|
|
1226
|
+
limit is documented rather than faked.
|
|
1227
|
+
- **Resolver order is not ours.** Pi composes tool-renderer resolvers in extension load order and
|
|
1228
|
+
offers no reordering API, so a resolver ahead of this package that does not call `next()` hides the
|
|
1229
|
+
resolver path. Adoption after construction exists for exactly that case (S10); it depends on Pi's
|
|
1230
|
+
`ToolExecutionComponent` carrying `toolName` and a writable `toolDefinition`, and reports the card
|
|
1231
|
+
as an `(adoption)` failure instead of throwing when it does not.
|
|
1232
|
+
- The sidebar surface has been proven against a fake host that models the real rail shape
|
|
1233
|
+
(`test/fake-sidebar.ts`), not in a running TUI. In particular, whether the widget Pi hands
|
|
1234
|
+
the `tui` through renders nothing visible is **unconfirmed** (see
|
|
1235
|
+
[architecture.md](architecture.md) §12).
|
|
1236
|
+
- `current()` must read live state and `action()` must persist before returning. This is a
|
|
1237
|
+
correctness rule the bridge cannot enforce at runtime; a plugin that caches a stale value
|
|
1238
|
+
renders a lying `●`.
|
|
1239
|
+
- No priority or insertion-index API exists: order is subscription order only.
|
|
1240
|
+
- The runtime handshake is proven under `node --test` with a simulated process (a reset
|
|
1241
|
+
`globalThis`, a fake `pi.events` and a freshly loaded example per scenario), not by two live Pi
|
|
1242
|
+
extensions loading in either order inside one real session. What it pins: the announcement's name,
|
|
1243
|
+
payload and once-per-process rule, and that an early plugin registers every surface it contributes
|
|
1244
|
+
from it.
|
|
1245
|
+
- The announcement is only as good as Pi's bus: a host without `pi.events` gets no event, which is
|
|
1246
|
+
why the symbol remains the authority and the example reports that state honestly instead of
|
|
1247
|
+
waiting for something that cannot arrive.
|
|
1248
|
+
- A card style's frame is measured in terminal columns (wide characters count 2, combining marks
|
|
1249
|
+
0); a style that mixes in its own images or escape tricks owns whatever the terminal does with it.
|
|
1250
|
+
- Card styles are proven against a fake conversation component that reproduces gentle-shell's
|
|
1251
|
+
`float` output, never in a running Pi TUI; the downstream-override conflict above is a property of
|
|
1252
|
+
a real session the fake cannot reproduce.
|
|
1253
|
+
- `registerCardStyle`, `registerTransform`, `registerToolDesign` and `requestRepaint` are optional
|
|
1254
|
+
on the published object: an object published by a copy older than that surface passes the gate but
|
|
1255
|
+
lacks it, so a plugin that needs one checks `typeof api.registerCardStyle === "function"` (or
|
|
1256
|
+
`registerTransform`, `registerToolDesign`, `requestRepaint`) first.
|
|
1257
|
+
- A design receives ROWS, a width and the PART it is drawing (`call` or `result`), never a renderer
|
|
1258
|
+
object and never the theme's shape. `part` is one of two literals; a design that ignores it is
|
|
1259
|
+
byte-identical to one written before the field existed, and a context produced by an older copy of
|
|
1260
|
+
the bridge carries no `part` at all — a design reads it as unknown (`ctx.part === "result"` is
|
|
1261
|
+
simply false) and keeps working.
|
|
1262
|
+
- A transform is a per-frame pass, so a slow transform is a slow frame; the bridge isolates its
|
|
1263
|
+
failures and clips its width, and can bound nothing else. It sees lines, never tool metadata.
|
|
1264
|
+
- The API is released (`0.3.0`). The `v1` symbol is the compatibility boundary: a breaking
|
|
1265
|
+
change takes a new key instead of mutating this one (§9). The `gentle-shell-bridge:ready` event is
|
|
1266
|
+
additive: a plugin that never subscribes loses nothing but the late-load case.
|